Full Code of teacat/chaturbate-dvr for AI

master c6eca20f3955 cached
28 files
86.2 KB
26.0k tokens
85 symbols
1 requests
Download .txt
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).

![Image](https://github.com/user-attachments/assets/d71f0aaa-e821-4371-9f48-658a137b42b6)

![Image](https://github.com/user-attachments/assets/43ab0a07-0ece-40ba-9a0f-045ca0316638)

 

# 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.

![localhost_8080_ (4)](https://github.com/user-attachments/assets/cbd859a9-4255-404b-b6bf-fa89342f7258)

_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

![sshot-2025-04-30-146](https://github.com/user-attachments/assets/69f4061b-29a2-48a7-ad57-0c86148805e2)

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 }}{{ . }}&NewLine;{{ 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
}
Download .txt
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
Download .txt
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.

Copied to clipboard!