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), "