Repository: teacat/chaturbate-dvr
Branch: master
Commit: c6eca20f3955
Files: 28
Total size: 86.2 KB
Directory structure:
gitextract_8oz87dn3/
├── .gitignore
├── .prettierignore
├── Dockerfile
├── LICENSE
├── README.md
├── README_DEV.md
├── channel/
│ ├── channel.go
│ ├── channel_file.go
│ └── channel_record.go
├── chaturbate/
│ └── chaturbate.go
├── config/
│ └── config.go
├── docker-compose.yml
├── entity/
│ └── entity.go
├── go.mod
├── go.sum
├── internal/
│ ├── internal.go
│ ├── internal_err.go
│ └── internal_req.go
├── main.go
├── manager/
│ └── manager.go
├── router/
│ ├── router.go
│ ├── router_handler.go
│ └── view/
│ ├── templates/
│ │ ├── channel_info.html
│ │ ├── index.html
│ │ └── site.webmanifest
│ └── view.go
└── server/
├── config.go
└── manager.go
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
videos
chaturbate-dvr
conf
bin
================================================
FILE: .prettierignore
================================================
**/*.html
================================================
FILE: Dockerfile
================================================
FROM golang:1.23-alpine AS builder
WORKDIR /workspace
COPY ./ ./
RUN go build -o chaturbate-dvr .
FROM scratch AS runnable
WORKDIR /usr/src/app
COPY --from=builder /workspace/chaturbate-dvr /chaturbate-dvr
ENTRYPOINT ["/chaturbate-dvr"]
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2024 TeaCat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
> [!CAUTION]
> **DEPRECATED**: This program has not been maintained since September 2025. 🪦 Thanks for all the support.
# Chaturbate DVR
A tool to record **multiple** Chaturbate streams. Supports macOS, Windows, Linux, and Docker. Favicon from [Twemoji](https://github.com/twitter/twemoji).


# Getting Started
Go to the [📦 Releases page](https://github.com/teacat/chaturbate-dvr/releases) and download the appropriate binary. (e.g., `x64_windows_chaturbate-dvr.exe`)
## 🌐 Launching the Web UI
```bash
# Windows
$ x64_windows_chaturbate-dvr.exe
# macOS / Linux
$ ./x64_linux_chaturbate-dvr
```
Then visit [`http://localhost:8080`](http://localhost:8080) in your browser.
## 💻 Using as a CLI Tool
```bash
# Windows
$ x64_windows_chaturbate-dvr.exe -u CHANNEL_USERNAME
# macOS / Linux
$ ./x64_linux_chaturbate-dvr -u CHANNEL_USERNAME
```
This starts recording immediately. The Web UI will be disabled.
## 🐳 Running with Docker
Pre-built image `yamiodymel/chaturbate-dvr` from [Docker Hub](https://hub.docker.com/r/yamiodymel/chaturbate-dvr):
```bash
# Run the container and save videos to ./videos
$ docker run -d \
--name my-dvr \
-p 8080:8080 \
-v "./videos:/usr/src/app/videos" \
-v "./conf:/usr/src/app/conf" \
yamiodymel/chaturbate-dvr
```
...Or build your own image using the Dockerfile in this repository.
```bash
# Build the image
$ docker build -t chaturbate-dvr .
# Run the container and save videos to ./videos
$ docker run -d \
--name my-dvr \
-p 8080:8080 \
-v "./videos:/usr/src/app/videos" \
-v "./conf:/usr/src/app/conf" \
chaturbate-dvr
```
...Or use [`docker-compose.yml`](https://github.com/teacat/chaturbate-dvr/blob/master/docker-compose.yml):
```bash
$ docker-compose up
```
Then visit [`http://localhost:8080`](http://localhost:8080) in your browser.
# 🧾 Command-Line Options
Available options:
```
--username value, -u value The username of the channel to record
--admin-username value Username for web authentication (optional)
--admin-password value Password for web authentication (optional)
--framerate value Desired framerate (FPS) (default: 30)
--resolution value Desired resolution (e.g., 1080 for 1080p) (default: 1080)
--pattern value Template for naming recorded videos (default: "videos/{{.Username}}_{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}")
--max-duration value Split video into segments every N minutes ('0' to disable) (default: 0)
--max-filesize value Split video into segments every N MB ('0' to disable) (default: 0)
--port value, -p value Port for the web interface and API (default: "8080")
--interval value Check if the channel is online every N minutes (default: 1)
--cookies value Cookies to use in the request (format: key=value; key2=value2)
--user-agent value Custom User-Agent for the request
--domain value Chaturbate domain to use (default: "https://chaturbate.global/")
--help, -h show help
--version, -v print the version
```
**Examples**:
```bash
# Record at 720p / 60fps
$ ./chaturbate-dvr -u yamiodymel -resolution 720 -framerate 60
# Split every 30 minutes
$ ./chaturbate-dvr -u yamiodymel -max-duration 30
# Split at 1024 MB
$ ./chaturbate-dvr -u yamiodymel -max-filesize 1024
# Custom filename format
$ ./chaturbate-dvr -u yamiodymel \
-pattern "video/{{.Username}}/{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}_{{.Sequence}}"
```
_Note: In Web UI mode, these flags serve as default values for new channels._
# 🍪 Cookies & User-Agent
You can set Cookies and User-Agent via the Web UI or command-line arguments.

_Note: Use semicolons to separate multiple cookies, e.g., `key1=value1; key2=value2`._
## ☁️ Bypass Cloudflare
1. Open [Chaturbate](https://chaturbate.com) in your browser and complete the Cloudflare check.
(Keep refresh with F5 if the check doesn't appear)
2. **DevTools (F12)** → **Application** → **Cookies** → `https://chaturbate.com` → Copy the `cf_clearance` value

3. User-Agent can be found using [WhatIsMyBrowser](https://www.whatismybrowser.com/detect/what-is-my-user-agent/), now run with `-cookies` and `-user-agent`:
```bash
$ ./chaturbate-dvr -u yamiodymel \
-cookies "cf_clearance=PASTE_YOUR_CF_CLEARANCE_HERE" \
-user-agent "PASTE_YOUR_USER_AGENT_HERE"
```
Example:
```bash
$ ./chaturbate-dvr -u yamiodymel \
-cookies "cf_clearance=i975JyJSMZUuEj2kIqfaClPB2dLomx3.iYo6RO1IIRg-1746019135-1.2.1.1-2CX..." \
-user-agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64)..."
```
## 🕵️ Record Private Shows
1. Login [Chaturbate](https://chaturbate.com) in your browser.
2. **DevTools (F12)** → **Application** → **Cookies** → `https://chaturbate.com` → Copy the `sessionid` value
3. Run with `-cookies`:
```bash
$ ./chaturbate-dvr -u yamiodymel -cookies "sessionid=PASTE_YOUR_SESSIONID_HERE"
```
# 📄 Filename Pattern
The format is based on [Go Template Syntax](https://pkg.go.dev/text/template), available variables are:
`{{.Username}}`, `{{.Year}}`, `{{.Month}}`, `{{.Day}}`, `{{.Hour}}`, `{{.Minute}}`, `{{.Second}}`, `{{.Sequence}}`
Default it hides the sequence if it's zero.
```
Pattern: {{.Username}}_{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}
Output: yamiodymel_2024-01-02_13-45-00.ts # Sequence won't be shown if it's zero.
Output: yamiodymel_2024-01-02_13-45-00_1.ts
```
**👀 or... The sequence can be shown even if it's zero.**
```
Pattern: {{.Username}}_{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}_{{.Sequence}}
Output: yamiodymel_2024-01-02_13-45-00_0.ts
Output: yamiodymel_2024-01-02_13-45-00_1.ts
```
**📁 or... Folder per each channel.**
```
Pattern: video/{{.Username}}/{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}_{{.Sequence}}
Output: video/yamiodymel/2024-01-02_13-45-00_0.ts
```
_Note: Files are saved in `.ts` format, and this is not configurable._
# 🤔 Frequently Asked Questions
**Q: The program closes immediately on Windows.**
> Open it via **Command Prompt**, the error message should appear. If needed, [create an issue](https://github.com/teacat/chaturbate-dvr/issues).
**Q: Error `listen tcp :8080: bind: An attempt was... by its access permissions`**
> The port `8080` is in use. Try another port with `-p 8123`, then visit [http://localhost:8123](http://localhost:8123).
>
> If that fails, run **Command Prompt** as Administrator and execute:
>
> ```bash
> $ net stop winnat
> $ net start winnat
> ```
**Q: Error `A connection attempt failed... host has failed to respond`**
> Likely a network issue (e.g., VPN, firewall, or blocked by Chaturbate). This cannot be fixed by the program.
**Q: Error `Channel was blocked by Cloudflare`**
> You've been temporarily blocked. See the [Cookies & User-Agent](#-cookies--user-agent) section to bypass.
**Q: Is Proxy or SOCKS5 supported?**
> Yes. You can launch the program using the `HTTPS_PROXY` environment variable:
>
> ```bash
> $ HTTPS_PROXY="socks5://127.0.0.1:9050" ./chaturbate-dvr -u CHANNEL_USERNAME
> ```
================================================
FILE: README_DEV.md
================================================
64-bit + arm64
```
GOOS=windows GOARCH=amd64 go build -o bin/x64_windows_chaturbate-dvr.exe &&
GOOS=darwin GOARCH=amd64 go build -o bin/x64_macos_chaturbate-dvr &&
GOOS=linux GOARCH=amd64 go build -o bin/x64_linux_chaturbate-dvr &&
GOOS=windows GOARCH=arm64 go build -o bin/arm64_windows_chaturbate-dvr.exe &&
GOOS=darwin GOARCH=arm64 go build -o bin/arm64_macos_chaturbate-dvr &&
GOOS=linux GOARCH=arm64 go build -o bin/arm64_linux_chaturbate-dvr
```
64-bit Windows, macOS, Linux:
```
GOOS=windows GOARCH=amd64 go build -o bin/x64_windows_chaturbate-dvr.exe &&
GOOS=darwin GOARCH=amd64 go build -o bin/x64_macos_chaturbate-dvr &&
GOOS=linux GOARCH=amd64 go build -o bin/x64_linux_chaturbate-dvr
```
arm64 Windows, macOS, Linux:
```
GOOS=windows GOARCH=arm64 go build -o bin/arm64_windows_chaturbate-dvr.exe &&
GOOS=darwin GOARCH=arm64 go build -o bin/arm64_macos_chaturbate-dvr &&
GOOS=linux GOARCH=arm64 go build -o bin/arm64_linux_chaturbate-dvr
```
Build Docker Tag:
```
docker build -t yamiodymel/chaturbate-dvr:2.0.0 .
docker push yamiodymel/chaturbate-dvr:2.0.0
docker image tag yamiodymel/chaturbate-dvr:2.0.0 yamiodymel/chaturbate-dvr:latest
docker push yamiodymel/chaturbate-dvr:latest
```
================================================
FILE: channel/channel.go
================================================
package channel
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/teacat/chaturbate-dvr/entity"
"github.com/teacat/chaturbate-dvr/internal"
"github.com/teacat/chaturbate-dvr/server"
)
// Channel represents a channel instance.
type Channel struct {
CancelFunc context.CancelFunc
LogCh chan string
UpdateCh chan bool
IsOnline bool
StreamedAt int64
Duration float64 // Seconds
Filesize int // Bytes
Sequence int
Logs []string
File *os.File
Config *entity.ChannelConfig
}
// New creates a new channel instance with the given manager and configuration.
func New(conf *entity.ChannelConfig) *Channel {
ch := &Channel{
LogCh: make(chan string),
UpdateCh: make(chan bool),
Config: conf,
CancelFunc: func() {},
}
go ch.Publisher()
return ch
}
// Publisher listens for log messages and updates from the channel
// and publishes once received.
func (ch *Channel) Publisher() {
for {
select {
case v := <-ch.LogCh:
// Append the log message to ch.Logs and keep only the last 100 rows
ch.Logs = append(ch.Logs, v)
if len(ch.Logs) > 100 {
ch.Logs = ch.Logs[len(ch.Logs)-100:]
}
server.Manager.Publish(entity.EventLog, ch.ExportInfo())
case <-ch.UpdateCh:
server.Manager.Publish(entity.EventUpdate, ch.ExportInfo())
}
}
}
// WithCancel creates a new context with a cancel function,
// then stores the cancel function in the channel's CancelFunc field.
//
// This is used to cancel the context when the channel is stopped or paused.
func (ch *Channel) WithCancel(ctx context.Context) (context.Context, context.CancelFunc) {
ctx, ch.CancelFunc = context.WithCancel(ctx)
return ctx, ch.CancelFunc
}
// Info logs an informational message.
func (ch *Channel) Info(format string, a ...any) {
ch.LogCh <- fmt.Sprintf("%s [INFO] %s", time.Now().Format("15:04"), fmt.Sprintf(format, a...))
log.Printf(" INFO [%s] %s", ch.Config.Username, fmt.Sprintf(format, a...))
}
// Error logs an error message.
func (ch *Channel) Error(format string, a ...any) {
ch.LogCh <- fmt.Sprintf("%s [ERROR] %s", time.Now().Format("15:04"), fmt.Sprintf(format, a...))
log.Printf("ERROR [%s] %s", ch.Config.Username, fmt.Sprintf(format, a...))
}
// ExportInfo exports the channel information as a ChannelInfo struct.
func (ch *Channel) ExportInfo() *entity.ChannelInfo {
var filename string
if ch.File != nil {
filename = ch.File.Name()
}
var streamedAt string
if ch.StreamedAt != 0 {
streamedAt = time.Unix(ch.StreamedAt, 0).Format("2006-01-02 15:04 AM")
}
return &entity.ChannelInfo{
IsOnline: ch.IsOnline,
IsPaused: ch.Config.IsPaused,
Username: ch.Config.Username,
MaxDuration: internal.FormatDuration(float64(ch.Config.MaxDuration * 60)), // MaxDuration from config is in minutes
MaxFilesize: internal.FormatFilesize(ch.Config.MaxFilesize * 1024 * 1024), // MaxFilesize from config is in MB
StreamedAt: streamedAt,
CreatedAt: ch.Config.CreatedAt,
Duration: internal.FormatDuration(ch.Duration),
Filesize: internal.FormatFilesize(ch.Filesize),
Filename: filename,
Logs: ch.Logs,
GlobalConfig: server.Config,
}
}
// Pause pauses the channel and cancels the context.
func (ch *Channel) Pause() {
// Stop the monitoring loop, this also updates `ch.IsOnline` to false
// `context.Canceled` → `ch.Monitor()` → `onRetry` → `ch.UpdateOnlineStatus(false)`.
ch.CancelFunc()
ch.Config.IsPaused = true
ch.Update()
ch.Info("channel paused")
}
// Stop stops the channel and cancels the context.
func (ch *Channel) Stop() {
// Stop the monitoring loop
ch.CancelFunc()
ch.Info("channel stopped")
}
// Resume resumes the channel monitoring.
//
// `startSeq` is used to prevent all channels from starting at the same time, preventing TooManyRequests errors.
// It's only be used when program starting and trying to resume all channels at once.
func (ch *Channel) Resume(startSeq int) {
ch.Config.IsPaused = false
ch.Update()
ch.Info("channel resumed")
<-time.After(time.Duration(startSeq) * time.Second)
go ch.Monitor()
}
// UpdateOnlineStatus updates the online status of the channel.
func (ch *Channel) UpdateOnlineStatus(isOnline bool) {
ch.IsOnline = isOnline
ch.Update()
}
================================================
FILE: channel/channel_file.go
================================================
package channel
import (
"bytes"
"errors"
"fmt"
"html/template"
"os"
"path/filepath"
"time"
)
// Pattern holds the date/time and sequence information for the filename pattern
type Pattern struct {
Username string
Year string
Month string
Day string
Hour string
Minute string
Second string
Sequence int
}
// NextFile prepares the next file to be created, by cleaning up the last file and generating a new one
func (ch *Channel) NextFile() error {
if err := ch.Cleanup(); err != nil {
return err
}
filename, err := ch.GenerateFilename()
if err != nil {
return err
}
if err := ch.CreateNewFile(filename); err != nil {
return err
}
// Increment the sequence number for the next file
ch.Sequence++
return nil
}
// Cleanup cleans the file and resets it, called when the stream errors out or before next file was created.
func (ch *Channel) Cleanup() error {
if ch.File == nil {
return nil
}
filename := ch.File.Name()
defer func() {
ch.Filesize = 0
ch.Duration = 0
}()
// Sync the file to ensure data is written to disk
if err := ch.File.Sync(); err != nil && !errors.Is(err, os.ErrClosed) {
return fmt.Errorf("sync file: %w", err)
}
if err := ch.File.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
return fmt.Errorf("close file: %w", err)
}
// Delete the empty file
fileInfo, err := os.Stat(filename)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("stat file delete zero file: %w", err)
}
if fileInfo != nil && fileInfo.Size() == 0 {
if err := os.Remove(filename); err != nil {
return fmt.Errorf("remove zero file: %w", err)
}
}
return nil
}
// GenerateFilename creates a filename based on the configured pattern and the current timestamp
func (ch *Channel) GenerateFilename() (string, error) {
var buf bytes.Buffer
// Parse the filename pattern defined in the channel's config
tpl, err := template.New("filename").Parse(ch.Config.Pattern)
if err != nil {
return "", fmt.Errorf("filename pattern error: %w", err)
}
// Get the current time based on the Unix timestamp when the stream was started
t := time.Unix(ch.StreamedAt, 0)
pattern := &Pattern{
Username: ch.Config.Username,
Sequence: ch.Sequence,
Year: t.Format("2006"),
Month: t.Format("01"),
Day: t.Format("02"),
Hour: t.Format("15"),
Minute: t.Format("04"),
Second: t.Format("05"),
}
if err := tpl.Execute(&buf, pattern); err != nil {
return "", fmt.Errorf("template execution error: %w", err)
}
return buf.String(), nil
}
// CreateNewFile creates a new file for the channel using the given filename
func (ch *Channel) CreateNewFile(filename string) error {
// Ensure the directory exists before creating the file
if err := os.MkdirAll(filepath.Dir(filename), 0777); err != nil {
return fmt.Errorf("mkdir all: %w", err)
}
// Open the file in append mode, create it if it doesn't exist
file, err := os.OpenFile(filename+".ts", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777)
if err != nil {
return fmt.Errorf("cannot open file: %s: %w", filename, err)
}
ch.File = file
return nil
}
// ShouldSwitchFile determines whether a new file should be created.
func (ch *Channel) ShouldSwitchFile() bool {
maxFilesizeBytes := ch.Config.MaxFilesize * 1024 * 1024
maxDurationSeconds := ch.Config.MaxDuration * 60
return (ch.Duration >= float64(maxDurationSeconds) && ch.Config.MaxDuration > 0) ||
(ch.Filesize >= maxFilesizeBytes && ch.Config.MaxFilesize > 0)
}
================================================
FILE: channel/channel_record.go
================================================
package channel
import (
"context"
"errors"
"fmt"
"time"
"github.com/avast/retry-go/v4"
"github.com/teacat/chaturbate-dvr/chaturbate"
"github.com/teacat/chaturbate-dvr/internal"
"github.com/teacat/chaturbate-dvr/server"
)
// Monitor starts monitoring the channel for live streams and records them.
func (ch *Channel) Monitor() {
client := chaturbate.NewClient()
ch.Info("starting to record `%s`", ch.Config.Username)
// Create a new context with a cancel function,
// the CancelFunc will be stored in the channel's CancelFunc field
// and will be called by `Pause` or `Stop` functions
ctx, _ := ch.WithCancel(context.Background())
var err error
for {
if err = ctx.Err(); err != nil {
break
}
pipeline := func() error {
return ch.RecordStream(ctx, client)
}
onRetry := func(_ uint, err error) {
ch.UpdateOnlineStatus(false)
if errors.Is(err, internal.ErrChannelOffline) || errors.Is(err, internal.ErrPrivateStream) {
ch.Info("channel is offline or private, try again in %d min(s)", server.Config.Interval)
} else if errors.Is(err, internal.ErrCloudflareBlocked) {
ch.Info("channel was blocked by Cloudflare; try with `-cookies` and `-user-agent`? try again in %d min(s)", server.Config.Interval)
} else if errors.Is(err, context.Canceled) {
// ...
} else {
ch.Error("on retry: %s: retrying in %d min(s)", err.Error(), server.Config.Interval)
}
}
if err = retry.Do(
pipeline,
retry.Context(ctx),
retry.Attempts(0),
retry.Delay(time.Duration(server.Config.Interval)*time.Minute),
retry.DelayType(retry.FixedDelay),
retry.OnRetry(onRetry),
); err != nil {
break
}
}
// Always cleanup when monitor exits, regardless of error
if err := ch.Cleanup(); err != nil {
ch.Error("cleanup on monitor exit: %s", err.Error())
}
// Log error if it's not a context cancellation
if err != nil && !errors.Is(err, context.Canceled) {
ch.Error("record stream: %s", err.Error())
}
}
// Update sends an update signal to the channel's update channel.
// This notifies the Server-sent Event to boradcast the channel information to the client.
func (ch *Channel) Update() {
ch.UpdateCh <- true
}
// RecordStream records the stream of the channel using the provided client.
// It retrieves the stream information and starts watching the segments.
func (ch *Channel) RecordStream(ctx context.Context, client *chaturbate.Client) error {
stream, err := client.GetStream(ctx, ch.Config.Username)
if err != nil {
return fmt.Errorf("get stream: %w", err)
}
ch.StreamedAt = time.Now().Unix()
ch.Sequence = 0
if err := ch.NextFile(); err != nil {
return fmt.Errorf("next file: %w", err)
}
// Ensure file is cleaned up when this function exits in any case
defer func() {
if err := ch.Cleanup(); err != nil {
ch.Error("cleanup on record stream exit: %s", err.Error())
}
}()
playlist, err := stream.GetPlaylist(ctx, ch.Config.Resolution, ch.Config.Framerate)
if err != nil {
return fmt.Errorf("get playlist: %w", err)
}
ch.UpdateOnlineStatus(true) // Update online status after `GetPlaylist` is OK
ch.Info("stream quality - resolution %dp (target: %dp), framerate %dfps (target: %dfps)", playlist.Resolution, ch.Config.Resolution, playlist.Framerate, ch.Config.Framerate)
return playlist.WatchSegments(ctx, ch.HandleSegment)
}
// HandleSegment processes and writes segment data to a file.
func (ch *Channel) HandleSegment(b []byte, duration float64) error {
if ch.Config.IsPaused {
return retry.Unrecoverable(internal.ErrPaused)
}
n, err := ch.File.Write(b)
if err != nil {
return fmt.Errorf("write file: %w", err)
}
ch.Filesize += n
ch.Duration += duration
ch.Info("duration: %s, filesize: %s", internal.FormatDuration(ch.Duration), internal.FormatFilesize(ch.Filesize))
// Send an SSE update to update the view
ch.Update()
if ch.ShouldSwitchFile() {
if err := ch.NextFile(); err != nil {
return fmt.Errorf("next file: %w", err)
}
ch.Info("max filesize or duration exceeded, new file created: %s", ch.File.Name())
return nil
}
return nil
}
================================================
FILE: chaturbate/chaturbate.go
================================================
package chaturbate
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/avast/retry-go/v4"
"github.com/grafov/m3u8"
"github.com/samber/lo"
"github.com/teacat/chaturbate-dvr/internal"
"github.com/teacat/chaturbate-dvr/server"
)
// roomDossierRegexp is used to extract the room dossier information from the HTML response.
var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
// Client represents an API client for interacting with Chaturbate.
type Client struct {
Req *internal.Req
}
// NewClient initializes and returns a new Client instance.
func NewClient() *Client {
return &Client{
Req: internal.NewReq(),
}
}
// GetStream fetches the stream information for a given username.
func (c *Client) GetStream(ctx context.Context, username string) (*Stream, error) {
return FetchStream(ctx, c.Req, username)
}
// FetchStream retrieves the streaming data from the given username's page.
func FetchStream(ctx context.Context, client *internal.Req, username string) (*Stream, error) {
body, err := client.Get(ctx, fmt.Sprintf("%s%s", server.Config.Domain, username))
if err != nil {
return nil, fmt.Errorf("failed to get page body: %w", err)
}
// Ensure that the playlist.m3u8 file is present in the response
if !strings.Contains(body, "playlist.m3u8") {
return nil, internal.ErrChannelOffline
}
return ParseStream(body)
}
// ParseStream extracts the HLS source URL from the given page body.
func ParseStream(body string) (*Stream, error) {
matches := roomDossierRegexp.FindStringSubmatch(body)
if len(matches) == 0 {
return nil, errors.New("room dossier not found")
}
// Decode Unicode escape sequences in the extracted JSON string
sourceData, err := strconv.Unquote(strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1))
if err != nil {
return nil, fmt.Errorf("failed to decode unicode: %w", err)
}
// Unmarshal JSON to extract HLS source URL
var room struct {
HLSSource string `json:"hls_source"`
}
if err := json.Unmarshal([]byte(sourceData), &room); err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
return &Stream{HLSSource: room.HLSSource}, nil
}
// Stream represents an HLS stream source.
type Stream struct {
HLSSource string
}
// GetPlaylist retrieves the playlist corresponding to the given resolution and framerate.
func (s *Stream) GetPlaylist(ctx context.Context, resolution, framerate int) (*Playlist, error) {
return FetchPlaylist(ctx, s.HLSSource, resolution, framerate)
}
// FetchPlaylist fetches and decodes the HLS playlist file.
func FetchPlaylist(ctx context.Context, hlsSource string, resolution, framerate int) (*Playlist, error) {
if hlsSource == "" {
return nil, errors.New("HLS source is empty")
}
resp, err := internal.NewReq().Get(ctx, hlsSource)
if err != nil {
return nil, fmt.Errorf("failed to fetch HLS source: %w", err)
}
return ParsePlaylist(resp, hlsSource, resolution, framerate)
}
// ParsePlaylist decodes the M3U8 playlist and extracts the variant streams.
func ParsePlaylist(resp, hlsSource string, resolution, framerate int) (*Playlist, error) {
p, _, err := m3u8.DecodeFrom(strings.NewReader(resp), true)
if err != nil {
return nil, fmt.Errorf("failed to decode m3u8 playlist: %w", err)
}
masterPlaylist, ok := p.(*m3u8.MasterPlaylist)
if !ok {
return nil, errors.New("invalid master playlist format")
}
return PickPlaylist(masterPlaylist, hlsSource, resolution, framerate)
}
// Playlist represents an HLS playlist containing variant streams.
type Playlist struct {
PlaylistURL string
RootURL string
Resolution int
Framerate int
}
// Resolution represents a video resolution and its corresponding framerate.
type Resolution struct {
Framerate map[int]string // [framerate]url
Width int
}
// PickPlaylist selects the best matching variant stream based on resolution and framerate.
func PickPlaylist(masterPlaylist *m3u8.MasterPlaylist, baseURL string, resolution, framerate int) (*Playlist, error) {
resolutions := map[int]*Resolution{}
// Extract available resolutions and framerates from the master playlist
for _, v := range masterPlaylist.Variants {
parts := strings.Split(v.Resolution, "x")
if len(parts) != 2 {
continue
}
width, err := strconv.Atoi(parts[1])
if err != nil {
return nil, fmt.Errorf("parse resolution: %w", err)
}
framerateVal := 30
if strings.Contains(v.Name, "FPS:60.0") {
framerateVal = 60
}
if _, exists := resolutions[width]; !exists {
resolutions[width] = &Resolution{Framerate: map[int]string{}, Width: width}
}
resolutions[width].Framerate[framerateVal] = v.URI
}
// Find exact match for requested resolution
variant, exists := resolutions[resolution]
if !exists {
// Filter resolutions below the requested resolution
candidates := lo.Filter(lo.Values(resolutions), func(r *Resolution, _ int) bool {
return r.Width < resolution
})
// Pick the highest resolution among the candidates
variant = lo.MaxBy(candidates, func(a, b *Resolution) bool {
return a.Width > b.Width
})
}
if variant == nil {
return nil, fmt.Errorf("resolution not found")
}
var (
finalResolution = variant.Width
finalFramerate = framerate
)
// Select the desired framerate, or fallback to the first available framerate
playlistURL, exists := variant.Framerate[framerate]
if !exists {
for fr, url := range variant.Framerate {
playlistURL = url
finalFramerate = fr
break
}
}
return &Playlist{
PlaylistURL: strings.TrimSuffix(baseURL, "playlist.m3u8") + playlistURL,
RootURL: strings.TrimSuffix(baseURL, "playlist.m3u8"),
Resolution: finalResolution,
Framerate: finalFramerate,
}, nil
}
// WatchHandler is a function type that processes video segments.
type WatchHandler func(b []byte, duration float64) error
// WatchSegments continuously fetches and processes video segments.
func (p *Playlist) WatchSegments(ctx context.Context, handler WatchHandler) error {
var (
client = internal.NewReq()
lastSeq = -1
)
for {
// Fetch the latest playlist
resp, err := client.Get(ctx, p.PlaylistURL)
if err != nil {
return fmt.Errorf("get playlist: %w", err)
}
pl, _, err := m3u8.DecodeFrom(strings.NewReader(resp), true)
if err != nil {
return fmt.Errorf("decode from: %w", err)
}
playlist, ok := pl.(*m3u8.MediaPlaylist)
if !ok {
return fmt.Errorf("cast to media playlist")
}
// Process new segments
for _, v := range playlist.Segments {
if v == nil {
continue
}
seq := internal.SegmentSeq(v.URI)
if seq == -1 || seq <= lastSeq {
continue
}
lastSeq = seq
// Fetch segment data with retry mechanism
pipeline := func() ([]byte, error) {
return client.GetBytes(ctx, fmt.Sprintf("%s%s", p.RootURL, v.URI))
}
resp, err := retry.DoWithData(
pipeline,
retry.Context(ctx),
retry.Attempts(3),
retry.Delay(600*time.Millisecond),
retry.DelayType(retry.FixedDelay),
)
if err != nil {
break
}
// Process the segment using the provided handler
if err := handler(resp, v.Duration); err != nil {
return fmt.Errorf("handler: %w", err)
}
}
<-time.After(1 * time.Second) // time.Duration(playlist.TargetDuration)
}
}
================================================
FILE: config/config.go
================================================
package config
import (
"github.com/teacat/chaturbate-dvr/entity"
"github.com/urfave/cli/v2"
)
// New initializes a new Config struct with values from the CLI context.
func New(c *cli.Context) (*entity.Config, error) {
return &entity.Config{
Version: c.App.Version,
Username: c.String("username"),
AdminUsername: c.String("admin-username"),
AdminPassword: c.String("admin-password"),
Framerate: c.Int("framerate"),
Resolution: c.Int("resolution"),
Pattern: c.String("pattern"),
MaxDuration: c.Int("max-duration"),
MaxFilesize: c.Int("max-filesize"),
Port: c.String("port"),
Interval: c.Int("interval"),
Cookies: c.String("cookies"),
UserAgent: c.String("user-agent"),
Domain: c.String("domain"),
}, nil
}
================================================
FILE: docker-compose.yml
================================================
version: "3.8"
services:
chaturbate-dvr:
image: yamiodymel/chaturbate-dvr
container_name: chaturbate-dvr
ports:
- "8080:8080"
volumes:
- ./videos:/usr/src/app/videos
- ./conf:/usr/src/app/conf
================================================
FILE: entity/entity.go
================================================
package entity
import (
"regexp"
"strings"
)
// Event represents the type of event for the channel.
type Event = string
const (
EventUpdate Event = "update"
EventLog Event = "log"
)
// ChannelConfig represents the configuration for a channel.
type ChannelConfig struct {
IsPaused bool `json:"is_paused"`
Username string `json:"username"`
Framerate int `json:"framerate"`
Resolution int `json:"resolution"`
Pattern string `json:"pattern"`
MaxDuration int `json:"max_duration"`
MaxFilesize int `json:"max_filesize"`
CreatedAt int64 `json:"created_at"`
}
func (c *ChannelConfig) Sanitize() {
c.Username = regexp.MustCompile(`[^a-zA-Z0-9_-]`).ReplaceAllString(c.Username, "")
c.Username = strings.TrimSpace(c.Username)
}
// ChannelInfo represents the information about a channel,
// mostly used for the template rendering.
type ChannelInfo struct {
IsOnline bool
IsPaused bool
Username string
Duration string
Filesize string
Filename string
StreamedAt string
MaxDuration string
MaxFilesize string
CreatedAt int64
Logs []string
GlobalConfig *Config // for nested template to access $.Config
}
// Config holds the configuration for the application.
type Config struct {
Version string
Username string
AdminUsername string
AdminPassword string
Framerate int
Resolution int
Pattern string
MaxDuration int
MaxFilesize int
Port string
Interval int
Cookies string
UserAgent string
Domain string
}
================================================
FILE: go.mod
================================================
module github.com/teacat/chaturbate-dvr
go 1.23.0
require (
github.com/avast/retry-go/v4 v4.6.1
github.com/gin-gonic/gin v1.10.0
github.com/grafov/m3u8 v0.12.1
github.com/r3labs/sse/v2 v2.10.0
github.com/samber/lo v1.49.1
github.com/urfave/cli/v2 v2.27.6
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
================================================
FILE: go.sum
================================================
github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=
github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s=
github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0=
github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y=
gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
================================================
FILE: internal/internal.go
================================================
package internal
import (
"fmt"
"regexp"
"strconv"
)
// FormatDuration converts a float64 duration (in seconds) to h:m:s format.
func FormatDuration(duration float64) string {
if duration == 0 {
return ""
}
var (
hours = int(duration) / 3600
minutes = (int(duration) % 3600) / 60
seconds = int(duration) % 60
)
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
}
// FormatFilesize converts an int filesize in bytes to a human-readable string (KB, MB, GB).
func FormatFilesize(filesize int) string {
if filesize == 0 {
return ""
}
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case filesize >= GB:
return fmt.Sprintf("%.2f GB", float64(filesize)/float64(GB))
case filesize >= MB:
return fmt.Sprintf("%.2f MB", float64(filesize)/float64(MB))
case filesize >= KB:
return fmt.Sprintf("%.2f KB", float64(filesize)/float64(KB))
default:
return fmt.Sprintf("%d bytes", filesize)
}
}
// SegmentSeq extracts the segment sequence number from a filename.
func SegmentSeq(filename string) int {
re := regexp.MustCompile(`_(\d+)\.ts$`)
match := re.FindStringSubmatch(filename)
if len(match) > 1 {
number, err := strconv.Atoi(match[1])
if err == nil {
return number
}
}
return -1
}
================================================
FILE: internal/internal_err.go
================================================
package internal
import "errors"
var (
ErrChannelExists = errors.New("channel exists")
ErrChannelNotFound = errors.New("channel not found")
ErrCloudflareBlocked = errors.New("blocked by Cloudflare; try with `-cookies` and `-user-agent`")
ErrAgeVerification = errors.New("age verification required; try with `-cookies` and `-user-agent`")
ErrChannelOffline = errors.New("channel offline")
ErrPrivateStream = errors.New("channel went offline or private")
ErrPaused = errors.New("channel paused")
ErrStopped = errors.New("channel stopped")
)
================================================
FILE: internal/internal_req.go
================================================
package internal
import (
"context"
"crypto/tls"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/teacat/chaturbate-dvr/server"
)
// Req represents an HTTP client with customized settings.
type Req struct {
client *http.Client
}
// NewReq creates a new HTTP client with specific transport configurations.
func NewReq() *Req {
return &Req{
client: &http.Client{
Transport: CreateTransport(),
},
}
}
// CreateTransport initializes a custom HTTP transport.
func CreateTransport() *http.Transport {
// The DefaultTransport allows user changes the proxy settings via environment variables
// such as HTTP_PROXY, HTTPS_PROXY.
defaultTransport := http.DefaultTransport.(*http.Transport)
newTransport := defaultTransport.Clone()
newTransport.TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
}
return newTransport
}
// Get sends an HTTP GET request and returns the response as a string.
func (h *Req) Get(ctx context.Context, url string) (string, error) {
resp, err := h.GetBytes(ctx, url)
if err != nil {
return "", fmt.Errorf("get bytes: %w", err)
}
return string(resp), nil
}
// GetBytes sends an HTTP GET request and returns the response as a byte slice.
func (h *Req) GetBytes(ctx context.Context, url string) ([]byte, error) {
req, cancel, err := CreateRequest(ctx, url)
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
defer cancel()
resp, err := h.client.Do(req)
if err != nil {
return nil, fmt.Errorf("client do: %w", err)
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
// Check for Cloudflare protection
if strings.Contains(string(b), "<title>Just a moment...</title>") {
return nil, ErrCloudflareBlocked
}
// Check for Age Verification
if strings.Contains(string(b), "Verify your age") {
return nil, ErrAgeVerification
}
if resp.StatusCode == http.StatusForbidden {
return nil, fmt.Errorf("forbidden: %w", ErrPrivateStream)
}
return b, err
}
// CreateRequest constructs an HTTP GET request with necessary headers.
func CreateRequest(ctx context.Context, url string) (*http.Request, context.CancelFunc, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second) // timed out after 10 seconds
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, cancel, err
}
SetRequestHeaders(req)
return req, cancel, nil
}
// SetRequestHeaders applies necessary headers to the request.
func SetRequestHeaders(req *http.Request) {
req.Header.Set("X-Requested-With", "XMLHttpRequest") // So Cloudflare would likely accept the request, and no Age Verification
if server.Config.UserAgent != "" {
req.Header.Set("User-Agent", server.Config.UserAgent)
}
if server.Config.Cookies != "" {
cookies := ParseCookies(server.Config.Cookies)
for name, value := range cookies {
req.AddCookie(&http.Cookie{Name: name, Value: value})
}
}
}
// ParseCookies converts a cookie string into a map.
func ParseCookies(cookieStr string) map[string]string {
cookies := make(map[string]string)
pairs := strings.Split(cookieStr, ";")
// Iterate over each cookie pair and extract key-value pairs
for _, pair := range pairs {
parts := strings.SplitN(strings.TrimSpace(pair), "=", 2)
if len(parts) == 2 {
// Trim spaces around key and value
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
// Store cookie name and value in the map
cookies[key] = value
}
}
return cookies
}
================================================
FILE: main.go
================================================
package main
import (
"fmt"
"log"
"os"
"github.com/teacat/chaturbate-dvr/config"
"github.com/teacat/chaturbate-dvr/entity"
"github.com/teacat/chaturbate-dvr/manager"
"github.com/teacat/chaturbate-dvr/router"
"github.com/teacat/chaturbate-dvr/server"
"github.com/urfave/cli/v2"
)
const logo = `
██████╗██╗ ██╗ █████╗ ████████╗██╗ ██╗██████╗ ██████╗ █████╗ ████████╗███████╗
██╔════╝██║ ██║██╔══██╗╚══██╔══╝██║ ██║██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██╔════╝
██║ ███████║███████║ ██║ ██║ ██║██████╔╝██████╔╝███████║ ██║ █████╗
██║ ██╔══██║██╔══██║ ██║ ██║ ██║██╔══██╗██╔══██╗██╔══██║ ██║ ██╔══╝
╚██████╗██║ ██║██║ ██║ ██║ ╚██████╔╝██║ ██║██████╔╝██║ ██║ ██║ ███████╗
╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝
██████╗ ██╗ ██╗██████╗
██╔══██╗██║ ██║██╔══██╗
██║ ██║██║ ██║██████╔╝
██║ ██║╚██╗ ██╔╝██╔══██╗
██████╔╝ ╚████╔╝ ██║ ██║
╚═════╝ ╚═══╝ ╚═╝ ╚═╝`
func main() {
app := &cli.App{
Name: "chaturbate-dvr",
Version: "2.0.3",
Usage: "Record your favorite Chaturbate streams automatically. 😎🫵",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"u"},
Usage: "The username of the channel to record",
Value: "",
},
&cli.StringFlag{
Name: "admin-username",
Usage: "Username for web authentication (optional)",
Value: "",
},
&cli.StringFlag{
Name: "admin-password",
Usage: "Password for web authentication (optional)",
Value: "",
},
&cli.IntFlag{
Name: "framerate",
Usage: "Desired framerate (FPS)",
Value: 30,
},
&cli.IntFlag{
Name: "resolution",
Usage: "Desired resolution (e.g., 1080 for 1080p)",
Value: 1080,
},
&cli.StringFlag{
Name: "pattern",
Usage: "Template for naming recorded videos",
Value: "videos/{{.Username}}_{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}",
},
&cli.IntFlag{
Name: "max-duration",
Usage: "Split video into segments every N minutes ('0' to disable)",
Value: 0,
},
&cli.IntFlag{
Name: "max-filesize",
Usage: "Split video into segments every N MB ('0' to disable)",
Value: 0,
},
&cli.StringFlag{
Name: "port",
Aliases: []string{"p"},
Usage: "Port for the web interface and API",
Value: "8080",
},
&cli.IntFlag{
Name: "interval",
Usage: "Check if the channel is online every N minutes",
Value: 1,
},
&cli.StringFlag{
Name: "cookies",
Usage: "Cookies to use in the request (format: key=value; key2=value2)",
Value: "",
},
&cli.StringFlag{
Name: "user-agent",
Usage: "Custom User-Agent for the request",
Value: "",
},
&cli.StringFlag{
Name: "domain",
Usage: "Chaturbate domain to use",
Value: "https://chaturbate.com/",
},
},
Action: start,
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
func start(c *cli.Context) error {
fmt.Println(logo)
var err error
server.Config, err = config.New(c)
if err != nil {
return fmt.Errorf("new config: %w", err)
}
server.Manager, err = manager.New()
if err != nil {
return fmt.Errorf("new manager: %w", err)
}
// init web interface if username is not provided
if server.Config.Username == "" {
fmt.Printf("👋 Visit http://localhost:%s to use the Web UI\n\n\n", c.String("port"))
if err := server.Manager.LoadConfig(); err != nil {
return fmt.Errorf("load config: %w", err)
}
return router.SetupRouter().Run(":" + c.String("port"))
}
// else create a channel with the provided username
if err := server.Manager.CreateChannel(&entity.ChannelConfig{
IsPaused: false,
Username: c.String("username"),
Framerate: c.Int("framerate"),
Resolution: c.Int("resolution"),
Pattern: c.String("pattern"),
MaxDuration: c.Int("max-duration"),
MaxFilesize: c.Int("max-filesize"),
}, false); err != nil {
return fmt.Errorf("create channel: %w", err)
}
// block forever
select {}
}
================================================
FILE: manager/manager.go
================================================
package manager
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strings"
"sync"
"github.com/r3labs/sse/v2"
"github.com/teacat/chaturbate-dvr/channel"
"github.com/teacat/chaturbate-dvr/entity"
"github.com/teacat/chaturbate-dvr/router/view"
)
// Manager is responsible for managing channels and their states.
type Manager struct {
Channels sync.Map
SSE *sse.Server
}
// New initializes a new Manager instance with an SSE server.
func New() (*Manager, error) {
server := sse.New()
server.SplitData = true
updateStream := server.CreateStream("updates")
updateStream.AutoReplay = false
return &Manager{
SSE: server,
}, nil
}
// SaveConfig saves the current channels and state to a JSON file.
func (m *Manager) SaveConfig() error {
var config []*entity.ChannelConfig
m.Channels.Range(func(key, value any) bool {
config = append(config, value.(*channel.Channel).Config)
return true
})
b, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
if err := os.MkdirAll("./conf", 0777); err != nil {
return fmt.Errorf("mkdir all conf: %w", err)
}
if err := os.WriteFile("./conf/channels.json", b, 0777); err != nil {
return fmt.Errorf("write file: %w", err)
}
return nil
}
// LoadConfig loads the channels from JSON and starts them.
func (m *Manager) LoadConfig() error {
b, err := os.ReadFile("./conf/channels.json")
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("read file: %w", err)
}
var config []*entity.ChannelConfig
if err := json.Unmarshal(b, &config); err != nil {
return fmt.Errorf("unmarshal: %w", err)
}
for i, conf := range config {
ch := channel.New(conf)
m.Channels.Store(conf.Username, ch)
if ch.Config.IsPaused {
ch.Info("channel was paused, waiting for resume")
continue
}
go ch.Resume(i)
}
return nil
}
// CreateChannel starts monitoring an M3U8 stream
func (m *Manager) CreateChannel(conf *entity.ChannelConfig, shouldSave bool) error {
conf.Sanitize()
ch := channel.New(conf)
// prevent duplicate channels
_, ok := m.Channels.Load(conf.Username)
if ok {
return fmt.Errorf("channel %s already exists", conf.Username)
}
m.Channels.Store(conf.Username, ch)
go ch.Resume(0)
if shouldSave {
if err := m.SaveConfig(); err != nil {
return fmt.Errorf("save config: %w", err)
}
}
return nil
}
// StopChannel stops the channel.
func (m *Manager) StopChannel(username string) error {
thing, ok := m.Channels.Load(username)
if !ok {
return nil
}
thing.(*channel.Channel).Stop()
m.Channels.Delete(username)
if err := m.SaveConfig(); err != nil {
return fmt.Errorf("save config: %w", err)
}
return nil
}
// PauseChannel pauses the channel.
func (m *Manager) PauseChannel(username string) error {
thing, ok := m.Channels.Load(username)
if !ok {
return nil
}
thing.(*channel.Channel).Pause()
if err := m.SaveConfig(); err != nil {
return fmt.Errorf("save config: %w", err)
}
return nil
}
// ResumeChannel resumes the channel.
func (m *Manager) ResumeChannel(username string) error {
thing, ok := m.Channels.Load(username)
if !ok {
return nil
}
thing.(*channel.Channel).Resume(0)
if err := m.SaveConfig(); err != nil {
return fmt.Errorf("save config: %w", err)
}
return nil
}
// ChannelInfo returns a list of channel information for the web UI.
func (m *Manager) ChannelInfo() []*entity.ChannelInfo {
var channels []*entity.ChannelInfo
// Iterate over the channels and append their information to the slice
m.Channels.Range(func(key, value any) bool {
channels = append(channels, value.(*channel.Channel).ExportInfo())
return true
})
sort.Slice(channels, func(i, j int) bool {
// First priority: Online channels
if channels[i].IsOnline != channels[j].IsOnline {
return channels[i].IsOnline
}
// Second priority: Alphabetical order by username
return strings.ToLower(channels[i].Username) < strings.ToLower(channels[j].Username)
})
return channels
}
// Publish sends an SSE event to the specified channel.
func (m *Manager) Publish(evt entity.Event, info *entity.ChannelInfo) {
switch evt {
case entity.EventUpdate:
var b bytes.Buffer
if err := view.InfoTpl.ExecuteTemplate(&b, "channel_info", info); err != nil {
fmt.Println("Error executing template:", err)
return
}
m.SSE.Publish("updates", &sse.Event{
Event: []byte(info.Username + "-info"),
Data: b.Bytes(),
})
case entity.EventLog:
m.SSE.Publish("updates", &sse.Event{
Event: []byte(info.Username + "-log"),
Data: []byte(strings.Join(info.Logs, "\n")),
})
}
}
// Subscriber handles SSE subscriptions for the specified channel.
func (m *Manager) Subscriber(w http.ResponseWriter, r *http.Request) {
m.SSE.ServeHTTP(w, r)
}
================================================
FILE: router/router.go
================================================
package router
import (
"embed"
"html/template"
"log"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/router/view"
"github.com/teacat/chaturbate-dvr/server"
)
// SetupRouter initializes and returns the Gin router.
func SetupRouter() *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
if err := LoadHTMLFromEmbedFS(r, view.FS, "templates/index.html", "templates/channel_info.html"); err != nil {
log.Fatalf("failed to load HTML templates: %v", err)
}
// Apply authentication if configured
SetupAuth(r)
// Serve static frontend files
SetupStatic(r)
// Register views
SetupViews(r)
return r
}
// SetupAuth applies basic authentication if credentials are provided.
func SetupAuth(r *gin.Engine) {
if server.Config.AdminUsername != "" && server.Config.AdminPassword != "" {
auth := gin.BasicAuth(gin.Accounts{
server.Config.AdminUsername: server.Config.AdminPassword,
})
r.Use(auth)
}
}
// SetupStatic serves static frontend files.
func SetupStatic(r *gin.Engine) {
fs, err := view.StaticFS()
if err != nil {
log.Fatalf("failed to initialize static files: %v", err)
}
r.StaticFS("/static", fs)
}
// setupViews registers HTML templates and view handlers.
func SetupViews(r *gin.Engine) {
r.GET("/", Index)
r.GET("/updates", Updates)
r.POST("/update_config", UpdateConfig)
r.POST("/create_channel", CreateChannel)
r.POST("/stop_channel/:username", StopChannel)
r.POST("/pause_channel/:username", PauseChannel)
r.POST("/resume_channel/:username", ResumeChannel)
}
// LoadHTMLFromEmbedFS loads specific HTML templates from an embedded filesystem and registers them with Gin.
func LoadHTMLFromEmbedFS(r *gin.Engine, embeddedFS embed.FS, files ...string) error {
templ := template.New("")
for _, file := range files {
content, err := embeddedFS.ReadFile(file)
if err != nil {
return err
}
_, err = templ.New(filepath.Base(file)).Parse(string(content))
if err != nil {
return err
}
}
// Set the parsed templates as the HTML renderer for Gin
r.SetHTMLTemplate(templ)
return nil
}
================================================
FILE: router/router_handler.go
================================================
package router
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/entity"
"github.com/teacat/chaturbate-dvr/server"
)
// IndexData represents the data structure for the index page.
type IndexData struct {
Config *entity.Config
Channels []*entity.ChannelInfo
}
// Index renders the index page with channel information.
func Index(c *gin.Context) {
c.HTML(200, "index.html", &IndexData{
Config: server.Config,
Channels: server.Manager.ChannelInfo(),
})
}
// CreateChannelRequest represents the request body for creating a channel.
type CreateChannelRequest struct {
Username string `form:"username" binding:"required"`
Framerate int `form:"framerate" binding:"required"`
Resolution int `form:"resolution" binding:"required"`
Pattern string `form:"pattern" binding:"required"`
MaxDuration int `form:"max_duration"`
MaxFilesize int `form:"max_filesize"`
}
// CreateChannel creates a new channel.
func CreateChannel(c *gin.Context) {
var req *CreateChannelRequest
if err := c.Bind(&req); err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("bind: %w", err))
return
}
for _, username := range strings.Split(req.Username, ",") {
server.Manager.CreateChannel(&entity.ChannelConfig{
IsPaused: false,
Username: username,
Framerate: req.Framerate,
Resolution: req.Resolution,
Pattern: req.Pattern,
MaxDuration: req.MaxDuration,
MaxFilesize: req.MaxFilesize,
CreatedAt: time.Now().Unix(),
}, true)
}
c.Redirect(http.StatusFound, "/")
}
// StopChannel stops a channel.
func StopChannel(c *gin.Context) {
server.Manager.StopChannel(c.Param("username"))
c.Redirect(http.StatusFound, "/")
}
// PauseChannel pauses a channel.
func PauseChannel(c *gin.Context) {
server.Manager.PauseChannel(c.Param("username"))
c.Redirect(http.StatusFound, "/")
}
// ResumeChannel resumes a paused channel.
func ResumeChannel(c *gin.Context) {
server.Manager.ResumeChannel(c.Param("username"))
c.Redirect(http.StatusFound, "/")
}
// Updates handles the SSE connection for updates.
func Updates(c *gin.Context) {
server.Manager.Subscriber(c.Writer, c.Request)
}
// UpdateConfigRequest represents the request body for updating configuration.
type UpdateConfigRequest struct {
Cookies string `form:"cookies"`
UserAgent string `form:"user_agent"`
}
// UpdateConfig updates the server configuration.
func UpdateConfig(c *gin.Context) {
var req *UpdateConfigRequest
if err := c.Bind(&req); err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("bind: %w", err))
return
}
server.Config.Cookies = req.Cookies
server.Config.UserAgent = req.UserAgent
c.Redirect(http.StatusFound, "/")
}
================================================
FILE: router/view/templates/channel_info.html
================================================
{{ define "channel_info" }}
<!-- Header -->
<div class="ts-grid is-middle-aligned">
<div class="column is-fluid">
<div class="ts-header">{{ .Username }}</div>
</div>
<div class="column">
{{ if and .IsOnline (not .IsPaused) }}
<span class="ts-badge is-small is-start-spaced">RECORDING</span>
{{ else if and (not .IsOnline) (not .IsPaused) }}
<span class="ts-badge is-secondary is-small is-start-spaced">OFFLINE</span>
{{ else if .IsPaused }}
<span class="ts-badge is-negative is-small is-start-spaced">PAUSED</span>
{{ end }}
</div>
</div>
<!-- / Header -->
<div class="ts-divider has-top-spaced"></div>
<!-- Info: Channel URL -->
<div class="ts-grid has-top-spaced">
<div class="column">
<span class="ts-icon is-link-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-small is-bold">Channel URL</div>
<a class="ts-text is-small is-link is-external-link" href="{{ .GlobalConfig.Domain }}{{ .Username }}" target="_blank"> {{ .GlobalConfig.Domain }}{{ .Username }}</a>
</div>
</div>
<!-- / Info: Channel URL -->
<!-- Info: Filename -->
<div class="ts-grid has-top-spaced">
<div class="column">
<span class="ts-icon is-folder-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-small is-bold">Filename</div>
{{ if .Filename }}
<div class="ts-text is-description">{{ .Filename }}</div>
{{ else }}
<span>-</span>
{{ end }}
</div>
</div>
<!-- / Info: Filename -->
<!-- Info: Last streamed at -->
<div class="ts-grid ts-grid has-top-spaced">
<div class="column">
<span class="ts-icon is-tower-broadcast-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-small is-bold">Last streamed at</div>
<div class="ts-text is-description">{{ if .StreamedAt }}{{ .StreamedAt }} {{ if and .IsOnline (not .IsPaused) }}(NOW){{ end }}{{ else }} - {{ end }}</div>
</div>
</div>
<!-- / Info: Last streamed at -->
<!-- Info: Segment duration -->
<div class="ts-grid ts-grid has-top-spaced">
<div class="column">
<span class="ts-icon is-clock-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-small is-bold">Segment duration</div>
<div class="ts-text is-description">{{ if .Duration }} {{ .Duration }} {{ if .MaxDuration }} / {{ .MaxDuration }} {{ end }} {{ else }} - {{ end }}</div>
</div>
</div>
<!-- / Info: Segment duration -->
<!-- Info: Segment filesize -->
<div class="ts-grid has-top-spaced">
<div class="column">
<span class="ts-icon is-chart-pie-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-small is-bold">Segment filesize</div>
<div class="ts-text is-description">{{ if .Filesize }} {{ .Filesize }} {{ if .MaxFilesize }} / {{ .MaxFilesize }} {{ end }} {{ else }} - {{ end }}</div>
</div>
</div>
<!-- / Info: Segment filesize -->
<!-- Actions -->
<div class="ts-grid is-2-columns has-top-spaced-large">
<div class="column">
{{ if .IsPaused }}
<form>
<button class="ts-button is-start-icon is-fluid" hx-post="/resume_channel/{{ .Username }}" hx-swap="none">
<span class="ts-icon is-play-icon"></span>
Resume
</button>
</form>
{{ else }}
<form>
<button type="submit" class="ts-button is-start-icon is-secondary is-fluid" hx-post="/pause_channel/{{ .Username }}" hx-swap="none">
<span class="ts-icon is-pause-icon"></span>
Pause
</button>
</form>
{{ end }}
</div>
<div class="column">
<form action="/stop_channel/{{ .Username }}" method="POST" onsubmit="return confirm('Are you sure you want to delete `{{ .Username }}` channel?')">
<button class="ts-button is-start-icon is-outlined is-negative is-fluid" >
<span class="ts-icon is-trash-icon"></span>
Delete
</button>
</form>
</div>
</div>
<!-- / Actions -->
{{ end }}
================================================
FILE: router/view/templates/index.html
================================================
<!DOCTYPE html>
<html lang="en" class="is-secondary">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocas/5.0.1/tocas.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/tocas/5.0.1/tocas.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet" />
<script src="/static/scripts/htmx.min.js" crossorigin="anonymous"></script>
<script src="/static/scripts/sse.min.js" crossorigin="anonymous"></script>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="manifest" href="/static/site.webmanifest">
<title>Chaturbate DVR</title>
</head>
<body hx-ext="sse">
<!-- Main Section -->
<div class="ts-container has-vertically-padded-big" style="--width: 990px">
<!-- 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-meta">
<div class="item">
<span id="recording-counter" class="ts-text is-description is-bold"></span>
</div>
<div class="item">
<span class="ts-text is-description is-bold">VERSION {{ .Config.Version }}</span>
</div>
</div>
</div>
<div class="column">
<button class="ts-button is-start-icon is-outlined" data-dialog="settings-dialog">
<span class="ts-icon is-gear-icon"></span>
Settings
</button>
</div>
<div class="column">
<button class="ts-button is-start-icon" data-dialog="create-dialog">
<span class="ts-icon is-plus-icon"></span>
Add Channel
</button>
</div>
</div>
<!-- / Header -->
{{ if not .Channels }}
<!-- Blankslate -->
<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 channels are currently recording</div>
<div class="description">Add a new Chaturbate channel to start recording.</div>
<div class="action">
<button class="ts-button is-start-icon" data-dialog="create-dialog">
<span class="ts-icon is-plus-icon"></span>
Add Channel
</button>
</div>
</div>
<!-- / Blankslate -->
{{ else }}
<!-- Channels -->
<div class="ts-wrap is-vertical has-top-spaced-large" sse-connect="/updates?stream=updates">
{{ range .Channels }}
<div class="ts-box is-horizontal">
<!-- Info Section -->
<div sse-swap="{{ .Username }}-info" class="ts-content is-padded has-break-all" style="width: 400px; line-height: 1.45; padding-right: 0">
{{ template "channel_info" . }}
</div>
<!-- / Info Section -->
<!-- Log Section -->
<div class="ts-content is-padded" style="flex: 1; gap: 0.8rem; display: flex; flex-direction: column">
<div class="ts-input" style="flex: 1">
<textarea class="has-full-height" readonly sse-swap="{{ .Username }}-log" style="scrollbar-width: thin">{{ range .Logs }}{{ . }}
{{ end }}</textarea>
</div>
<div>
<label class="ts-switch is-small" style="display: flex">
<input type="checkbox" checked />
Auto-Update & Scroll Logs
</label>
</div>
</div>
<!-- / Log Section -->
</div>
{{ end }}
</div>
<!-- / Channels -->
{{ end }}
</div>
<!-- / Main Section -->
<!-- Settings Dialog -->
<dialog id="settings-dialog" class="ts-modal" style="--width: 680px">
<div class="content">
<form action="/update_config" method="POST">
<div class="ts-content is-horizontally-padded is-secondary">
<div class="ts-grid">
<div class="column is-fluid">
<div class="ts-header">Settings</div>
</div>
<div class="column">
<button type="reset" class="ts-close is-rounded is-large is-secondary" data-dialog="settings-dialog"></button>
</div>
</div>
</div>
<div class="ts-divider"></div>
<div class="ts-content is-vertically-padded">
<!-- Cookies -->
<div class="ts-control is-wide">
<div class="label">Cookies</div>
<div class="content">
<div class="ts-input">
<textarea name="cookies" rows="5">{{ .Config.Cookies }}</textarea>
</div>
<div class="ts-text is-description has-top-spaced-small">Use semicolons to separate multiple cookies, e.g., "key1=value1; key2=value2". See <a class="ts-text is-link" href="https://github.com/teacat/chaturbate-dvr/?tab=readme-ov-file#-cookies--user-agent" target="_blank">README</a> for details.</div>
</div>
</div>
<!-- / Cookies -->
<!-- User Agent -->
<div class="ts-control is-wide has-top-spaced-large">
<div class="label">User Agent</div>
<div class="content">
<div class="ts-input">
<textarea name="user_agent" rows="5">{{ .Config.UserAgent }}</textarea>
</div>
<div class="ts-text is-description has-top-spaced-small">User-Agent can be found using tools like <a class="ts-text is-link" href="https://www.whatismybrowser.com/detect/what-is-my-user-agent/" target="_blank">WhatIsMyBrowser</a>.</div>
</div>
</div>
<!-- / User Agent -->
</div>
<div class="ts-divider"></div>
<div class="ts-content is-secondary is-horizontally-padded">
<div class="ts-grid is-middle-aligned">
<div class="column is-fluid">
<div class="ts-text is-description">
<span class="ts-icon is-triangle-exclamation-icon is-end-spaced"></span>
Changes will be reverted after the program restarts
</div>
</div>
<div class="column">
<button type="reset" class="ts-button is-outlined is-secondary" data-dialog="settings-dialog">Cancel</button>
</div>
<div class="column">
<button type="submit" class="ts-button is-primary">Apply</button>
</div>
</div>
</div>
</form>
</div>
</dialog>
<!-- / Settings Dialog -->
<!-- Create Dialog -->
<dialog id="create-dialog" class="ts-modal" style="--width: 680px">
<div class="content">
<form action="/create_channel" method="POST">
<div class="ts-content is-horizontally-padded is-secondary">
<div class="ts-grid">
<div class="column is-fluid">
<div class="ts-header">Add Channel</div>
</div>
<div class="column">
<button type="reset" class="ts-close is-rounded is-large is-secondary" data-dialog="create-dialog"></button>
</div>
</div>
</div>
<div class="ts-divider"></div>
<div class="ts-content is-vertically-padded">
<!-- Channel Username -->
<div class="ts-control is-wide">
<div class="label">Channel Username</div>
<div class="content">
<div class="ts-input is-start-labeled">
<div class="label">{{ .Config.Domain }}</div>
<input type="text" name="username" autofocus required />
</div>
<div class="ts-text is-description has-top-spaced-small">Use commas to separate multiple channel names, e.g. "channel1, channel2, channel3".</div>
</div>
</div>
<!-- / Channel Username -->
<!-- Resolution -->
<div class="ts-control is-wide has-top-spaced-large">
<div class="label">Resolution</div>
<div class="content">
<div class="ts-select">
<select name="resolution">
<option value="2160" {{ if eq .Config.Resolution 2160 }}selected{{ end }}>4K</option>
<option value="1440" {{ if eq .Config.Resolution 1440 }}selected{{ end }}>2K</option>
<option value="1080" {{ if eq .Config.Resolution 1080 }}selected{{ end }}>1080p</option>
<option value="720" {{ if eq .Config.Resolution 720 }}selected{{ end }}>720p</option>
<option value="540" {{ if eq .Config.Resolution 540 }}selected{{ end }}>540p</option>
<option value="480" {{ if eq .Config.Resolution 480 }}selected{{ end }}>480p</option>
<option value="240" {{ if eq .Config.Resolution 240 }}selected{{ end }}>240p</option>
</select>
</div>
<div class="ts-text is-description has-top-spaced-small">The lower resolution will be used if the selected resolution is not available.</div>
</div>
</div>
<!-- / Resolution -->
<!-- Framerate -->
<div class="ts-control is-wide has-top-spaced-large">
<div class="label">Framerate</div>
<div class="content is-padded">
<div class="ts-wrap is-compact is-vertical">
<label class="ts-radio">
<input type="radio" name="framerate" value="60" {{ if eq .Config.Framerate 60 }}checked{{ end }} />
60 FPS (or lower)
</label>
<label class="ts-radio">
<input type="radio" name="framerate" value="30" {{ if eq .Config.Framerate 30 }}checked{{ end }} />
30 FPS
</label>
</div>
</div>
</div>
<!-- / Framerate -->
<!-- Filename Pattern -->
<div class="ts-control is-wide has-top-spaced-large">
<div class="label">Filename Pattern</div>
<div class="content">
<div class="ts-input">
<input type="text" name="pattern" value="{{ .Config.Pattern }}" />
</div>
<div class="ts-text is-description has-top-spaced-small">
See the <a class="ts-text is-external-link is-link" href="https://github.com/teacat/chaturbate-dvr" target="_blank">README</a> for details.
</div>
</div>
</div>
<!-- / Filename Pattern -->
<div class="ts-divider has-vertically-spaced-large"></div>
<!-- Splitting Options -->
<div class="ts-control is-wide has-top-spaced">
<div class="label">Splitting Options</div>
<div class="content">
<div class="ts-content is-padded is-secondary">
<div class="ts-grid is-relaxed is-2-columns">
<div class="column">
<div class="ts-text is-bold">Max Filesize</div>
<div class="ts-input is-end-labeled has-top-spaced-small">
<input type="number" name="max_filesize" value="{{ .Config.MaxFilesize }}" />
<span class="label">MB</span>
</div>
</div>
<div class="column">
<div class="ts-text is-bold">Max Duration</div>
<div class="ts-input is-end-labeled has-top-spaced-small">
<input type="number" name="max_duration" value="{{ .Config.MaxDuration }}" />
<span class="label">Min(s)</span>
</div>
</div>
</div>
<div class="ts-text is-description has-top-spaced">Splitting will be disabled if both options are 0.</div>
</div>
</div>
</div>
<!-- / Splitting Options -->
</div>
<div class="ts-divider"></div>
<div class="ts-content is-secondary is-horizontally-padded">
<div class="ts-wrap is-end-aligned">
<button type="reset" class="ts-button is-outlined is-secondary" data-dialog="create-dialog">Cancel</button>
<button type="submit" class="ts-button is-primary">Add Channel</button>
</div>
</div>
</form>
</div>
</dialog>
<!-- / Create Dialog -->
<script>
// updateRecordingCount counts recording channels
function updateRecordingCount() {
// Count badges that contain "RECORDING" text (not secondary or negative)
let count = 0;
document.querySelectorAll('.ts-badge').forEach(badge => {
if (badge.textContent.trim() === 'RECORDING') {
count++;
}
});
const counter_el = document.getElementById('recording-counter');
if (count > 0) {
counter_el.textContent = `${count} ${count === 1 ? 'CHANNEL IS' : 'CHANNELS ARE'} RECORDING`;
} else {
counter_el.textContent = `NO RECORDING`;
}
}
// before content was swapped by HTMX
document.body.addEventListener("htmx:sseBeforeMessage", function (e) {
// ignore anything with `-log` content swap if "auto-update" was unchecked
let sswe_id = e.detail.elt.getAttribute('sse-swap')
if (sswe_id && sswe_id.endsWith("-log") ) {
if (!e.detail.elt.closest(".ts-box").querySelector("[type=checkbox]").checked) {
e.preventDefault()
return
}
}
// else scroll the textarea to bottom with async trick
setTimeout(() => {
let textarea = e.detail.elt.closest(".ts-box").querySelector("textarea")
textarea.scrollTop = textarea.scrollHeight
}, 0)
})
// after content was swapped by HTMX (for status updates)
document.body.addEventListener("htmx:sseMessage", function (e) {
// only update recording count if the content swap is for channel info
let sswe_id = e.detail.elt.getAttribute('sse-swap')
if (sswe_id && sswe_id.endsWith("-info") ) {
updateRecordingCount();
}
})
// Initial count on page load
document.addEventListener("DOMContentLoaded", function() {
updateRecordingCount();
});
document.body.querySelectorAll("textarea").forEach((textarea) => {
textarea.scrollTop = textarea.scrollHeight
})
</script>
</body>
</html>
================================================
FILE: router/view/templates/site.webmanifest
================================================
{"name":"","short_name":"","icons":[{"src":"/static/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/static/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
================================================
FILE: router/view/view.go
================================================
package view
import (
"embed"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
)
//go:embed templates
var FS embed.FS
// InfoTpl is a template for rendering channel information.
var InfoTpl *template.Template
func init() {
var err error
InfoTpl, err = template.New("update").ParseFS(FS, "templates/channel_info.html")
if err != nil {
log.Fatalf("failed to parse template: %v", err)
}
}
// StaticFS initializes the static file system for serving frontend files.
func StaticFS() (http.FileSystem, error) {
frontendFS, err := fs.Sub(FS, "templates")
if err != nil {
return nil, fmt.Errorf("failed to initialize static files: %w", err)
}
return http.FS(frontendFS), nil
}
================================================
FILE: server/config.go
================================================
package server
import "github.com/teacat/chaturbate-dvr/entity"
var Config *entity.Config
================================================
FILE: server/manager.go
================================================
package server
import (
"net/http"
"github.com/teacat/chaturbate-dvr/entity"
)
var Manager IManager
type IManager interface {
CreateChannel(conf *entity.ChannelConfig, shouldSave bool) error
StopChannel(username string) error
PauseChannel(username string) error
ResumeChannel(username string) error
ChannelInfo() []*entity.ChannelInfo
Publish(name string, ch *entity.ChannelInfo)
Subscriber(w http.ResponseWriter, r *http.Request)
LoadConfig() error
SaveConfig() error
}
gitextract_8oz87dn3/
├── .gitignore
├── .prettierignore
├── Dockerfile
├── LICENSE
├── README.md
├── README_DEV.md
├── channel/
│ ├── channel.go
│ ├── channel_file.go
│ └── channel_record.go
├── chaturbate/
│ └── chaturbate.go
├── config/
│ └── config.go
├── docker-compose.yml
├── entity/
│ └── entity.go
├── go.mod
├── go.sum
├── internal/
│ ├── internal.go
│ ├── internal_err.go
│ └── internal_req.go
├── main.go
├── manager/
│ └── manager.go
├── router/
│ ├── router.go
│ ├── router_handler.go
│ └── view/
│ ├── templates/
│ │ ├── channel_info.html
│ │ ├── index.html
│ │ └── site.webmanifest
│ └── view.go
└── server/
├── config.go
└── manager.go
SYMBOL INDEX (85 symbols across 14 files)
FILE: channel/channel.go
type Channel (line 16) | type Channel struct
method Publisher (line 48) | func (ch *Channel) Publisher() {
method WithCancel (line 69) | func (ch *Channel) WithCancel(ctx context.Context) (context.Context, c...
method Info (line 75) | func (ch *Channel) Info(format string, a ...any) {
method Error (line 81) | func (ch *Channel) Error(format string, a ...any) {
method ExportInfo (line 87) | func (ch *Channel) ExportInfo() *entity.ChannelInfo {
method Pause (line 113) | func (ch *Channel) Pause() {
method Stop (line 124) | func (ch *Channel) Stop() {
method Resume (line 135) | func (ch *Channel) Resume(startSeq int) {
method UpdateOnlineStatus (line 146) | func (ch *Channel) UpdateOnlineStatus(isOnline bool) {
function New (line 34) | func New(conf *entity.ChannelConfig) *Channel {
FILE: channel/channel_file.go
type Pattern (line 14) | type Pattern struct
method NextFile (line 26) | func (ch *Channel) NextFile() error {
method Cleanup (line 44) | func (ch *Channel) Cleanup() error {
method GenerateFilename (line 77) | func (ch *Channel) GenerateFilename() (string, error) {
method CreateNewFile (line 106) | func (ch *Channel) CreateNewFile(filename string) error {
method ShouldSwitchFile (line 124) | func (ch *Channel) ShouldSwitchFile() bool {
FILE: channel/channel_record.go
method Monitor (line 16) | func (ch *Channel) Monitor() {
method Update (line 72) | func (ch *Channel) Update() {
method RecordStream (line 78) | func (ch *Channel) RecordStream(ctx context.Context, client *chaturbate....
method HandleSegment (line 109) | func (ch *Channel) HandleSegment(b []byte, duration float64) error {
FILE: chaturbate/chaturbate.go
type Client (line 24) | type Client struct
method GetStream (line 36) | func (c *Client) GetStream(ctx context.Context, username string) (*Str...
function NewClient (line 29) | func NewClient() *Client {
function FetchStream (line 41) | func FetchStream(ctx context.Context, client *internal.Req, username str...
function ParseStream (line 56) | func ParseStream(body string) (*Stream, error) {
type Stream (line 80) | type Stream struct
method GetPlaylist (line 85) | func (s *Stream) GetPlaylist(ctx context.Context, resolution, framerat...
function FetchPlaylist (line 90) | func FetchPlaylist(ctx context.Context, hlsSource string, resolution, fr...
function ParsePlaylist (line 104) | func ParsePlaylist(resp, hlsSource string, resolution, framerate int) (*...
type Playlist (line 119) | type Playlist struct
method WatchSegments (line 198) | func (p *Playlist) WatchSegments(ctx context.Context, handler WatchHan...
type Resolution (line 127) | type Resolution struct
function PickPlaylist (line 133) | func PickPlaylist(masterPlaylist *m3u8.MasterPlaylist, baseURL string, r...
type WatchHandler (line 195) | type WatchHandler
FILE: config/config.go
function New (line 9) | func New(c *cli.Context) (*entity.Config, error) {
FILE: entity/entity.go
constant EventUpdate (line 12) | EventUpdate Event = "update"
constant EventLog (line 13) | EventLog Event = "log"
type ChannelConfig (line 17) | type ChannelConfig struct
method Sanitize (line 28) | func (c *ChannelConfig) Sanitize() {
type ChannelInfo (line 35) | type ChannelInfo struct
type Config (line 51) | type Config struct
FILE: internal/internal.go
function FormatDuration (line 10) | func FormatDuration(duration float64) string {
function FormatFilesize (line 23) | func FormatFilesize(filesize int) string {
function SegmentSeq (line 45) | func SegmentSeq(filename string) int {
FILE: internal/internal_req.go
type Req (line 16) | type Req struct
method Get (line 43) | func (h *Req) Get(ctx context.Context, url string) (string, error) {
method GetBytes (line 52) | func (h *Req) GetBytes(ctx context.Context, url string) ([]byte, error) {
function NewReq (line 21) | func NewReq() *Req {
function CreateTransport (line 30) | func CreateTransport() *http.Transport {
function CreateRequest (line 87) | func CreateRequest(ctx context.Context, url string) (*http.Request, cont...
function SetRequestHeaders (line 99) | func SetRequestHeaders(req *http.Request) {
function ParseCookies (line 114) | func ParseCookies(cookieStr string) map[string]string {
FILE: main.go
constant logo (line 16) | logo = `
function main (line 30) | func main() {
function start (line 111) | func start(c *cli.Context) error {
FILE: manager/manager.go
type Manager (line 20) | type Manager struct
method SaveConfig (line 40) | func (m *Manager) SaveConfig() error {
method LoadConfig (line 62) | func (m *Manager) LoadConfig() error {
method CreateChannel (line 90) | func (m *Manager) CreateChannel(conf *entity.ChannelConfig, shouldSave...
method StopChannel (line 112) | func (m *Manager) StopChannel(username string) error {
method PauseChannel (line 127) | func (m *Manager) PauseChannel(username string) error {
method ResumeChannel (line 141) | func (m *Manager) ResumeChannel(username string) error {
method ChannelInfo (line 155) | func (m *Manager) ChannelInfo() []*entity.ChannelInfo {
method Publish (line 177) | func (m *Manager) Publish(evt entity.Event, info *entity.ChannelInfo) {
method Subscriber (line 198) | func (m *Manager) Subscriber(w http.ResponseWriter, r *http.Request) {
function New (line 26) | func New() (*Manager, error) {
FILE: router/router.go
function SetupRouter (line 15) | func SetupRouter() *gin.Engine {
function SetupAuth (line 34) | func SetupAuth(r *gin.Engine) {
function SetupStatic (line 44) | func SetupStatic(r *gin.Engine) {
function SetupViews (line 53) | func SetupViews(r *gin.Engine) {
function LoadHTMLFromEmbedFS (line 65) | func LoadHTMLFromEmbedFS(r *gin.Engine, embeddedFS embed.FS, files ...st...
FILE: router/router_handler.go
type IndexData (line 15) | type IndexData struct
function Index (line 21) | func Index(c *gin.Context) {
type CreateChannelRequest (line 29) | type CreateChannelRequest struct
function CreateChannel (line 39) | func CreateChannel(c *gin.Context) {
function StopChannel (line 62) | func StopChannel(c *gin.Context) {
function PauseChannel (line 69) | func PauseChannel(c *gin.Context) {
function ResumeChannel (line 76) | func ResumeChannel(c *gin.Context) {
function Updates (line 83) | func Updates(c *gin.Context) {
type UpdateConfigRequest (line 88) | type UpdateConfigRequest struct
function UpdateConfig (line 94) | func UpdateConfig(c *gin.Context) {
FILE: router/view/view.go
function init (line 18) | func init() {
function StaticFS (line 28) | func StaticFS() (http.FileSystem, error) {
FILE: server/manager.go
type IManager (line 11) | type IManager interface
Condensed preview — 28 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (96K chars).
[
{
"path": ".gitignore",
"chars": 30,
"preview": "videos\nchaturbate-dvr\nconf\nbin"
},
{
"path": ".prettierignore",
"chars": 9,
"preview": "**/*.html"
},
{
"path": "Dockerfile",
"chars": 240,
"preview": "FROM golang:1.23-alpine AS builder\nWORKDIR /workspace\n\nCOPY ./ ./\nRUN go build -o chaturbate-dvr .\n\nFROM scratch AS runn"
},
{
"path": "LICENSE",
"chars": 1062,
"preview": "MIT License\n\nCopyright (c) 2024 TeaCat\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof "
},
{
"path": "README.md",
"chars": 7778,
"preview": "> [!CAUTION]\n> **DEPRECATED**: This program has not been maintained since September 2025. 🪦 Thanks for all the support. "
},
{
"path": "README_DEV.md",
"chars": 1207,
"preview": "64-bit + arm64\n\n```\nGOOS=windows GOARCH=amd64 go build -o bin/x64_windows_chaturbate-dvr.exe &&\nGOOS=darwin GOARCH=amd64"
},
{
"path": "channel/channel.go",
"chars": 4238,
"preview": "package channel\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/teacat/chaturbate-dvr/entity\"\n\t\"github.co"
},
{
"path": "channel/channel_file.go",
"chars": 3490,
"preview": "package channel\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n)\n\n// Pattern holds "
},
{
"path": "channel/channel_record.go",
"chars": 4087,
"preview": "package channel\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/avast/retry-go/v4\"\n\t\"github.com/teacat/chatu"
},
{
"path": "chaturbate/chaturbate.go",
"chars": 7288,
"preview": "package chaturbate\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"git"
},
{
"path": "config/config.go",
"chars": 798,
"preview": "package config\n\nimport (\n\t\"github.com/teacat/chaturbate-dvr/entity\"\n\t\"github.com/urfave/cli/v2\"\n)\n\n// New initializes a "
},
{
"path": "docker-compose.yml",
"chars": 266,
"preview": "version: \"3.8\"\n\nservices:\n chaturbate-dvr:\n image: yamiodymel/chaturbate-dvr\n container_name: chaturbat"
},
{
"path": "entity/entity.go",
"chars": 1564,
"preview": "package entity\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n)\n\n// Event represents the type of event for the channel.\ntype Event = str"
},
{
"path": "go.mod",
"chars": 1797,
"preview": "module github.com/teacat/chaturbate-dvr\n\ngo 1.23.0\n\nrequire (\n\tgithub.com/avast/retry-go/v4 v4.6.1\n\tgithub.com/gin-gonic"
},
{
"path": "go.sum",
"chars": 10543,
"preview": "github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=\ngithub.com/avast/retry-go/v4 v4.6.1/"
},
{
"path": "internal/internal.go",
"chars": 1254,
"preview": "package internal\n\nimport (\n\t\"fmt\"\n\t\"regexp\"\n\t\"strconv\"\n)\n\n// FormatDuration converts a float64 duration (in seconds) to "
},
{
"path": "internal/internal_err.go",
"chars": 585,
"preview": "package internal\n\nimport \"errors\"\n\nvar (\n\tErrChannelExists = errors.New(\"channel exists\")\n\tErrChannelNotFound = er"
},
{
"path": "internal/internal_req.go",
"chars": 3529,
"preview": "package internal\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/teacat/cha"
},
{
"path": "main.go",
"chars": 4076,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\n\t\"github.com/teacat/chaturbate-dvr/config\"\n\t\"github.com/teacat/chaturbate-dv"
},
{
"path": "manager/manager.go",
"chars": 4782,
"preview": "package manager\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/r3"
},
{
"path": "router/router.go",
"chars": 2087,
"preview": "package router\n\nimport (\n\t\"embed\"\n\t\"html/template\"\n\t\"log\"\n\t\"path/filepath\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/tea"
},
{
"path": "router/router_handler.go",
"chars": 2756,
"preview": "package router\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/teacat/chaturba"
},
{
"path": "router/view/templates/channel_info.html",
"chars": 4196,
"preview": "{{ define \"channel_info\" }}\n\n<!-- Header -->\n<div class=\"ts-grid is-middle-aligned\">\n <div class=\"column is-fluid\">\n "
},
{
"path": "router/view/templates/index.html",
"chars": 19047,
"preview": "<!DOCTYPE html>\n<html lang=\"en\" class=\"is-secondary\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"vi"
},
{
"path": "router/view/templates/site.webmanifest",
"chars": 277,
"preview": "{\"name\":\"\",\"short_name\":\"\",\"icons\":[{\"src\":\"/static/android-chrome-192x192.png\",\"sizes\":\"192x192\",\"type\":\"image/png\"},{\""
},
{
"path": "router/view/view.go",
"chars": 690,
"preview": "package view\n\nimport (\n\t\"embed\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"io/fs\"\n\t\"log\"\n\t\"net/http\"\n)\n\n//go:embed templates\nvar FS embed"
},
{
"path": "server/config.go",
"chars": 92,
"preview": "package server\n\nimport \"github.com/teacat/chaturbate-dvr/entity\"\n\nvar Config *entity.Config\n"
},
{
"path": "server/manager.go",
"chars": 486,
"preview": "package server\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/teacat/chaturbate-dvr/entity\"\n)\n\nvar Manager IManager\n\ntype IManager "
}
]
About this extraction
This page contains the full source code of the teacat/chaturbate-dvr GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 28 files (86.2 KB), approximately 26.0k tokens, and a symbol index with 85 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.