[
  {
    "path": ".gitignore",
    "content": "videos\nchaturbate-dvr\nconf\nbin"
  },
  {
    "path": ".prettierignore",
    "content": "**/*.html"
  },
  {
    "path": "Dockerfile",
    "content": "FROM golang:1.23-alpine AS builder\nWORKDIR /workspace\n\nCOPY ./ ./\nRUN go build -o chaturbate-dvr .\n\nFROM scratch AS runnable\nWORKDIR /usr/src/app\n\nCOPY --from=builder /workspace/chaturbate-dvr /chaturbate-dvr\n\nENTRYPOINT [\"/chaturbate-dvr\"]"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 TeaCat\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "README.md",
    "content": "> [!CAUTION]\n> **DEPRECATED**: This program has not been maintained since September 2025. 🪦 Thanks for all the support. \n\n# Chaturbate DVR\n\nA tool to record **multiple** Chaturbate streams. Supports macOS, Windows, Linux, and Docker. Favicon from [Twemoji](https://github.com/twitter/twemoji).\n\n![Image](https://github.com/user-attachments/assets/d71f0aaa-e821-4371-9f48-658a137b42b6)\n\n![Image](https://github.com/user-attachments/assets/43ab0a07-0ece-40ba-9a0f-045ca0316638)\n\n&nbsp;\n\n# Getting Started\n\nGo to the [📦 Releases page](https://github.com/teacat/chaturbate-dvr/releases) and download the appropriate binary. (e.g., `x64_windows_chaturbate-dvr.exe`)\n\n&nbsp;\n\n## 🌐 Launching the Web UI\n\n```bash\n# Windows\n$ x64_windows_chaturbate-dvr.exe\n\n# macOS / Linux\n$ ./x64_linux_chaturbate-dvr\n```\n\nThen visit [`http://localhost:8080`](http://localhost:8080) in your browser.\n\n&nbsp;\n\n## 💻 Using as a CLI Tool\n\n```bash\n# Windows\n$ x64_windows_chaturbate-dvr.exe -u CHANNEL_USERNAME\n\n# macOS / Linux\n$ ./x64_linux_chaturbate-dvr -u CHANNEL_USERNAME\n```\n\nThis starts recording immediately. The Web UI will be disabled.\n\n&nbsp;\n\n## 🐳 Running with Docker\n\nPre-built image `yamiodymel/chaturbate-dvr` from [Docker Hub](https://hub.docker.com/r/yamiodymel/chaturbate-dvr):\n\n```bash\n# Run the container and save videos to ./videos\n$ docker run -d \\\n    --name my-dvr \\\n    -p 8080:8080 \\\n    -v \"./videos:/usr/src/app/videos\" \\\n    -v \"./conf:/usr/src/app/conf\" \\\n    yamiodymel/chaturbate-dvr\n```\n\n...Or build your own image using the Dockerfile in this repository.\n\n```bash\n# Build the image\n$ docker build -t chaturbate-dvr .\n\n# Run the container and save videos to ./videos\n$ docker run -d \\\n    --name my-dvr \\\n    -p 8080:8080 \\\n    -v \"./videos:/usr/src/app/videos\" \\\n    -v \"./conf:/usr/src/app/conf\" \\\n    chaturbate-dvr\n```\n\n...Or use [`docker-compose.yml`](https://github.com/teacat/chaturbate-dvr/blob/master/docker-compose.yml):\n\n```bash\n$ docker-compose up\n```\n\nThen visit [`http://localhost:8080`](http://localhost:8080) in your browser.\n\n&nbsp;\n\n# 🧾 Command-Line Options\n\nAvailable options:\n\n```\n--username value, -u value  The username of the channel to record\n--admin-username value      Username for web authentication (optional)\n--admin-password value      Password for web authentication (optional)\n--framerate value           Desired framerate (FPS) (default: 30)\n--resolution value          Desired resolution (e.g., 1080 for 1080p) (default: 1080)\n--pattern value             Template for naming recorded videos (default: \"videos/{{.Username}}_{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}\")\n--max-duration value        Split video into segments every N minutes ('0' to disable) (default: 0)\n--max-filesize value        Split video into segments every N MB ('0' to disable) (default: 0)\n--port value, -p value      Port for the web interface and API (default: \"8080\")\n--interval value            Check if the channel is online every N minutes (default: 1)\n--cookies value             Cookies to use in the request (format: key=value; key2=value2)\n--user-agent value          Custom User-Agent for the request\n--domain value              Chaturbate domain to use (default: \"https://chaturbate.global/\")\n--help, -h                  show help\n--version, -v               print the version\n```\n\n**Examples**:\n\n```bash\n# Record at 720p / 60fps\n$ ./chaturbate-dvr -u yamiodymel -resolution 720 -framerate 60\n\n# Split every 30 minutes\n$ ./chaturbate-dvr -u yamiodymel -max-duration 30\n\n# Split at 1024 MB\n$ ./chaturbate-dvr -u yamiodymel -max-filesize 1024\n\n# Custom filename format\n$ ./chaturbate-dvr -u yamiodymel \\\n    -pattern \"video/{{.Username}}/{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}_{{.Sequence}}\"\n```\n\n_Note: In Web UI mode, these flags serve as default values for new channels._\n\n&nbsp;\n\n# 🍪 Cookies & User-Agent\n\nYou can set Cookies and User-Agent via the Web UI or command-line arguments.\n\n![localhost_8080_ (4)](https://github.com/user-attachments/assets/cbd859a9-4255-404b-b6bf-fa89342f7258)\n\n_Note: Use semicolons to separate multiple cookies, e.g., `key1=value1; key2=value2`._\n\n&nbsp;\n\n## ☁️ Bypass Cloudflare\n\n1. Open [Chaturbate](https://chaturbate.com) in your browser and complete the Cloudflare check.\n\n    (Keep refresh with F5 if the check doesn't appear)\n\n2. **DevTools (F12)** → **Application** → **Cookies** → `https://chaturbate.com` → Copy the `cf_clearance` value\n\n![sshot-2025-04-30-146](https://github.com/user-attachments/assets/69f4061b-29a2-48a7-ad57-0c86148805e2)\n\n3. User-Agent can be found using [WhatIsMyBrowser](https://www.whatismybrowser.com/detect/what-is-my-user-agent/), now run with `-cookies` and `-user-agent`:\n\n    ```bash\n    $ ./chaturbate-dvr -u yamiodymel \\\n        -cookies \"cf_clearance=PASTE_YOUR_CF_CLEARANCE_HERE\" \\\n        -user-agent \"PASTE_YOUR_USER_AGENT_HERE\"\n    ```\n\n    Example:\n\n    ```bash\n    $ ./chaturbate-dvr -u yamiodymel \\\n        -cookies \"cf_clearance=i975JyJSMZUuEj2kIqfaClPB2dLomx3.iYo6RO1IIRg-1746019135-1.2.1.1-2CX...\" \\\n        -user-agent \"Mozilla/5.0 (Windows NT 10.0; Win64; x64)...\"\n    ```\n\n&nbsp;\n\n## 🕵️ Record Private Shows\n\n1. Login [Chaturbate](https://chaturbate.com) in your browser.\n\n2. **DevTools (F12)** → **Application** → **Cookies** → `https://chaturbate.com` → Copy the `sessionid` value\n\n3. Run with `-cookies`:\n\n    ```bash\n    $ ./chaturbate-dvr -u yamiodymel -cookies \"sessionid=PASTE_YOUR_SESSIONID_HERE\"\n    ```\n\n&nbsp;\n\n# 📄 Filename Pattern\n\nThe format is based on [Go Template Syntax](https://pkg.go.dev/text/template), available variables are:\n\n`{{.Username}}`, `{{.Year}}`, `{{.Month}}`, `{{.Day}}`, `{{.Hour}}`, `{{.Minute}}`, `{{.Second}}`, `{{.Sequence}}`\n\n&nbsp;\n\nDefault it hides the sequence if it's zero.\n\n```\nPattern: {{.Username}}_{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}\n Output: yamiodymel_2024-01-02_13-45-00.ts    # Sequence won't be shown if it's zero.\n Output: yamiodymel_2024-01-02_13-45-00_1.ts\n```\n\n**👀 or... The sequence can be shown even if it's zero.**\n\n```\nPattern: {{.Username}}_{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}_{{.Sequence}}\n Output: yamiodymel_2024-01-02_13-45-00_0.ts\n Output: yamiodymel_2024-01-02_13-45-00_1.ts\n```\n\n**📁 or... Folder per each channel.**\n\n```\nPattern: video/{{.Username}}/{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}_{{.Sequence}}\n Output: video/yamiodymel/2024-01-02_13-45-00_0.ts\n```\n\n_Note: Files are saved in `.ts` format, and this is not configurable._\n\n&nbsp;\n\n# 🤔 Frequently Asked Questions\n\n**Q: The program closes immediately on Windows.**\n\n> Open it via **Command Prompt**, the error message should appear. If needed, [create an issue](https://github.com/teacat/chaturbate-dvr/issues).\n\n&nbsp;\n\n**Q: Error `listen tcp :8080: bind: An attempt was... by its access permissions`**\n\n> The port `8080` is in use. Try another port with `-p 8123`, then visit [http://localhost:8123](http://localhost:8123).\n>\n> If that fails, run **Command Prompt** as Administrator and execute:\n>\n> ```bash\n> $ net stop winnat\n> $ net start winnat\n> ```\n\n&nbsp;\n\n**Q: Error `A connection attempt failed... host has failed to respond`**\n\n> Likely a network issue (e.g., VPN, firewall, or blocked by Chaturbate). This cannot be fixed by the program.\n\n&nbsp;\n\n**Q: Error `Channel was blocked by Cloudflare`**\n\n> You've been temporarily blocked. See the [Cookies & User-Agent](#-cookies--user-agent) section to bypass.\n\n&nbsp;\n\n**Q: Is Proxy or SOCKS5 supported?**\n\n> Yes. You can launch the program using the `HTTPS_PROXY` environment variable:\n>\n> ```bash\n> $ HTTPS_PROXY=\"socks5://127.0.0.1:9050\" ./chaturbate-dvr -u CHANNEL_USERNAME\n> ```\n"
  },
  {
    "path": "README_DEV.md",
    "content": "64-bit + arm64\n\n```\nGOOS=windows GOARCH=amd64 go build -o bin/x64_windows_chaturbate-dvr.exe &&\nGOOS=darwin GOARCH=amd64 go build -o bin/x64_macos_chaturbate-dvr &&\nGOOS=linux GOARCH=amd64 go build -o bin/x64_linux_chaturbate-dvr &&\nGOOS=windows GOARCH=arm64 go build -o bin/arm64_windows_chaturbate-dvr.exe &&\nGOOS=darwin GOARCH=arm64 go build -o bin/arm64_macos_chaturbate-dvr &&\nGOOS=linux GOARCH=arm64 go build -o bin/arm64_linux_chaturbate-dvr\n```\n\n64-bit Windows, macOS, Linux:\n\n```\nGOOS=windows GOARCH=amd64 go build -o bin/x64_windows_chaturbate-dvr.exe &&\nGOOS=darwin GOARCH=amd64 go build -o bin/x64_macos_chaturbate-dvr &&\nGOOS=linux GOARCH=amd64 go build -o bin/x64_linux_chaturbate-dvr\n```\n\narm64 Windows, macOS, Linux:\n\n```\nGOOS=windows GOARCH=arm64 go build -o bin/arm64_windows_chaturbate-dvr.exe &&\nGOOS=darwin GOARCH=arm64 go build -o bin/arm64_macos_chaturbate-dvr &&\nGOOS=linux GOARCH=arm64 go build -o bin/arm64_linux_chaturbate-dvr\n```\n\nBuild Docker Tag:\n\n```\ndocker build -t yamiodymel/chaturbate-dvr:2.0.0 .\ndocker push yamiodymel/chaturbate-dvr:2.0.0\ndocker image tag yamiodymel/chaturbate-dvr:2.0.0 yamiodymel/chaturbate-dvr:latest\ndocker push yamiodymel/chaturbate-dvr:latest\n```\n"
  },
  {
    "path": "channel/channel.go",
    "content": "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.com/teacat/chaturbate-dvr/internal\"\n\t\"github.com/teacat/chaturbate-dvr/server\"\n)\n\n// Channel represents a channel instance.\ntype Channel struct {\n\tCancelFunc context.CancelFunc\n\tLogCh      chan string\n\tUpdateCh   chan bool\n\n\tIsOnline   bool\n\tStreamedAt int64\n\tDuration   float64 // Seconds\n\tFilesize   int     // Bytes\n\tSequence   int\n\n\tLogs []string\n\n\tFile   *os.File\n\tConfig *entity.ChannelConfig\n}\n\n// New creates a new channel instance with the given manager and configuration.\nfunc New(conf *entity.ChannelConfig) *Channel {\n\tch := &Channel{\n\t\tLogCh:      make(chan string),\n\t\tUpdateCh:   make(chan bool),\n\t\tConfig:     conf,\n\t\tCancelFunc: func() {},\n\t}\n\tgo ch.Publisher()\n\n\treturn ch\n}\n\n// Publisher listens for log messages and updates from the channel\n// and publishes once received.\nfunc (ch *Channel) Publisher() {\n\tfor {\n\t\tselect {\n\t\tcase v := <-ch.LogCh:\n\t\t\t// Append the log message to ch.Logs and keep only the last 100 rows\n\t\t\tch.Logs = append(ch.Logs, v)\n\t\t\tif len(ch.Logs) > 100 {\n\t\t\t\tch.Logs = ch.Logs[len(ch.Logs)-100:]\n\t\t\t}\n\t\t\tserver.Manager.Publish(entity.EventLog, ch.ExportInfo())\n\n\t\tcase <-ch.UpdateCh:\n\t\t\tserver.Manager.Publish(entity.EventUpdate, ch.ExportInfo())\n\t\t}\n\t}\n}\n\n// WithCancel creates a new context with a cancel function,\n// then stores the cancel function in the channel's CancelFunc field.\n//\n// This is used to cancel the context when the channel is stopped or paused.\nfunc (ch *Channel) WithCancel(ctx context.Context) (context.Context, context.CancelFunc) {\n\tctx, ch.CancelFunc = context.WithCancel(ctx)\n\treturn ctx, ch.CancelFunc\n}\n\n// Info logs an informational message.\nfunc (ch *Channel) Info(format string, a ...any) {\n\tch.LogCh <- fmt.Sprintf(\"%s [INFO] %s\", time.Now().Format(\"15:04\"), fmt.Sprintf(format, a...))\n\tlog.Printf(\" INFO [%s] %s\", ch.Config.Username, fmt.Sprintf(format, a...))\n}\n\n// Error logs an error message.\nfunc (ch *Channel) Error(format string, a ...any) {\n\tch.LogCh <- fmt.Sprintf(\"%s [ERROR] %s\", time.Now().Format(\"15:04\"), fmt.Sprintf(format, a...))\n\tlog.Printf(\"ERROR [%s] %s\", ch.Config.Username, fmt.Sprintf(format, a...))\n}\n\n// ExportInfo exports the channel information as a ChannelInfo struct.\nfunc (ch *Channel) ExportInfo() *entity.ChannelInfo {\n\tvar filename string\n\tif ch.File != nil {\n\t\tfilename = ch.File.Name()\n\t}\n\tvar streamedAt string\n\tif ch.StreamedAt != 0 {\n\t\tstreamedAt = time.Unix(ch.StreamedAt, 0).Format(\"2006-01-02 15:04 AM\")\n\t}\n\treturn &entity.ChannelInfo{\n\t\tIsOnline:     ch.IsOnline,\n\t\tIsPaused:     ch.Config.IsPaused,\n\t\tUsername:     ch.Config.Username,\n\t\tMaxDuration:  internal.FormatDuration(float64(ch.Config.MaxDuration * 60)), // MaxDuration from config is in minutes\n\t\tMaxFilesize:  internal.FormatFilesize(ch.Config.MaxFilesize * 1024 * 1024), // MaxFilesize from config is in MB\n\t\tStreamedAt:   streamedAt,\n\t\tCreatedAt:    ch.Config.CreatedAt,\n\t\tDuration:     internal.FormatDuration(ch.Duration),\n\t\tFilesize:     internal.FormatFilesize(ch.Filesize),\n\t\tFilename:     filename,\n\t\tLogs:         ch.Logs,\n\t\tGlobalConfig: server.Config,\n\t}\n}\n\n// Pause pauses the channel and cancels the context.\nfunc (ch *Channel) Pause() {\n\t// Stop the monitoring loop, this also updates `ch.IsOnline` to false\n\t// `context.Canceled` → `ch.Monitor()` → `onRetry` → `ch.UpdateOnlineStatus(false)`.\n\tch.CancelFunc()\n\n\tch.Config.IsPaused = true\n\tch.Update()\n\tch.Info(\"channel paused\")\n}\n\n// Stop stops the channel and cancels the context.\nfunc (ch *Channel) Stop() {\n\t// Stop the monitoring loop\n\tch.CancelFunc()\n\n\tch.Info(\"channel stopped\")\n}\n\n// Resume resumes the channel monitoring.\n//\n// `startSeq` is used to prevent all channels from starting at the same time, preventing TooManyRequests errors.\n// It's only be used when program starting and trying to resume all channels at once.\nfunc (ch *Channel) Resume(startSeq int) {\n\tch.Config.IsPaused = false\n\n\tch.Update()\n\tch.Info(\"channel resumed\")\n\n\t<-time.After(time.Duration(startSeq) * time.Second)\n\tgo ch.Monitor()\n}\n\n// UpdateOnlineStatus updates the online status of the channel.\nfunc (ch *Channel) UpdateOnlineStatus(isOnline bool) {\n\tch.IsOnline = isOnline\n\tch.Update()\n}\n"
  },
  {
    "path": "channel/channel_file.go",
    "content": "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 the date/time and sequence information for the filename pattern\ntype Pattern struct {\n\tUsername string\n\tYear     string\n\tMonth    string\n\tDay      string\n\tHour     string\n\tMinute   string\n\tSecond   string\n\tSequence int\n}\n\n// NextFile prepares the next file to be created, by cleaning up the last file and generating a new one\nfunc (ch *Channel) NextFile() error {\n\tif err := ch.Cleanup(); err != nil {\n\t\treturn err\n\t}\n\tfilename, err := ch.GenerateFilename()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := ch.CreateNewFile(filename); err != nil {\n\t\treturn err\n\t}\n\n\t// Increment the sequence number for the next file\n\tch.Sequence++\n\treturn nil\n}\n\n// Cleanup cleans the file and resets it, called when the stream errors out or before next file was created.\nfunc (ch *Channel) Cleanup() error {\n\tif ch.File == nil {\n\t\treturn nil\n\t}\n\tfilename := ch.File.Name()\n\n\tdefer func() {\n\t\tch.Filesize = 0\n\t\tch.Duration = 0\n\t}()\n\n\t// Sync the file to ensure data is written to disk\n\tif err := ch.File.Sync(); err != nil && !errors.Is(err, os.ErrClosed) {\n\t\treturn fmt.Errorf(\"sync file: %w\", err)\n\t}\n\tif err := ch.File.Close(); err != nil && !errors.Is(err, os.ErrClosed) {\n\t\treturn fmt.Errorf(\"close file: %w\", err)\n\t}\n\n\t// Delete the empty file\n\tfileInfo, err := os.Stat(filename)\n\tif err != nil && !os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"stat file delete zero file: %w\", err)\n\t}\n\tif fileInfo != nil && fileInfo.Size() == 0 {\n\t\tif err := os.Remove(filename); err != nil {\n\t\t\treturn fmt.Errorf(\"remove zero file: %w\", err)\n\t\t}\n\t}\n\treturn nil\n}\n\n// GenerateFilename creates a filename based on the configured pattern and the current timestamp\nfunc (ch *Channel) GenerateFilename() (string, error) {\n\tvar buf bytes.Buffer\n\n\t// Parse the filename pattern defined in the channel's config\n\ttpl, err := template.New(\"filename\").Parse(ch.Config.Pattern)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"filename pattern error: %w\", err)\n\t}\n\n\t// Get the current time based on the Unix timestamp when the stream was started\n\tt := time.Unix(ch.StreamedAt, 0)\n\tpattern := &Pattern{\n\t\tUsername: ch.Config.Username,\n\t\tSequence: ch.Sequence,\n\t\tYear:     t.Format(\"2006\"),\n\t\tMonth:    t.Format(\"01\"),\n\t\tDay:      t.Format(\"02\"),\n\t\tHour:     t.Format(\"15\"),\n\t\tMinute:   t.Format(\"04\"),\n\t\tSecond:   t.Format(\"05\"),\n\t}\n\n\tif err := tpl.Execute(&buf, pattern); err != nil {\n\t\treturn \"\", fmt.Errorf(\"template execution error: %w\", err)\n\t}\n\treturn buf.String(), nil\n}\n\n// CreateNewFile creates a new file for the channel using the given filename\nfunc (ch *Channel) CreateNewFile(filename string) error {\n\n\t// Ensure the directory exists before creating the file\n\tif err := os.MkdirAll(filepath.Dir(filename), 0777); err != nil {\n\t\treturn fmt.Errorf(\"mkdir all: %w\", err)\n\t}\n\n\t// Open the file in append mode, create it if it doesn't exist\n\tfile, err := os.OpenFile(filename+\".ts\", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot open file: %s: %w\", filename, err)\n\t}\n\n\tch.File = file\n\treturn nil\n}\n\n// ShouldSwitchFile determines whether a new file should be created.\nfunc (ch *Channel) ShouldSwitchFile() bool {\n\tmaxFilesizeBytes := ch.Config.MaxFilesize * 1024 * 1024\n\tmaxDurationSeconds := ch.Config.MaxDuration * 60\n\n\treturn (ch.Duration >= float64(maxDurationSeconds) && ch.Config.MaxDuration > 0) ||\n\t\t(ch.Filesize >= maxFilesizeBytes && ch.Config.MaxFilesize > 0)\n}\n"
  },
  {
    "path": "channel/channel_record.go",
    "content": "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/chaturbate-dvr/chaturbate\"\n\t\"github.com/teacat/chaturbate-dvr/internal\"\n\t\"github.com/teacat/chaturbate-dvr/server\"\n)\n\n// Monitor starts monitoring the channel for live streams and records them.\nfunc (ch *Channel) Monitor() {\n\tclient := chaturbate.NewClient()\n\tch.Info(\"starting to record `%s`\", ch.Config.Username)\n\n\t// Create a new context with a cancel function,\n\t// the CancelFunc will be stored in the channel's CancelFunc field\n\t// and will be called by `Pause` or `Stop` functions\n\tctx, _ := ch.WithCancel(context.Background())\n\n\tvar err error\n\tfor {\n\t\tif err = ctx.Err(); err != nil {\n\t\t\tbreak\n\t\t}\n\n\t\tpipeline := func() error {\n\t\t\treturn ch.RecordStream(ctx, client)\n\t\t}\n\t\tonRetry := func(_ uint, err error) {\n\t\t\tch.UpdateOnlineStatus(false)\n\n\t\t\tif errors.Is(err, internal.ErrChannelOffline) || errors.Is(err, internal.ErrPrivateStream) {\n\t\t\t\tch.Info(\"channel is offline or private, try again in %d min(s)\", server.Config.Interval)\n\t\t\t} else if errors.Is(err, internal.ErrCloudflareBlocked) {\n\t\t\t\tch.Info(\"channel was blocked by Cloudflare; try with `-cookies` and `-user-agent`? try again in %d min(s)\", server.Config.Interval)\n\t\t\t} else if errors.Is(err, context.Canceled) {\n\t\t\t\t// ...\n\t\t\t} else {\n\t\t\t\tch.Error(\"on retry: %s: retrying in %d min(s)\", err.Error(), server.Config.Interval)\n\t\t\t}\n\t\t}\n\t\tif err = retry.Do(\n\t\t\tpipeline,\n\t\t\tretry.Context(ctx),\n\t\t\tretry.Attempts(0),\n\t\t\tretry.Delay(time.Duration(server.Config.Interval)*time.Minute),\n\t\t\tretry.DelayType(retry.FixedDelay),\n\t\t\tretry.OnRetry(onRetry),\n\t\t); err != nil {\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// Always cleanup when monitor exits, regardless of error\n\tif err := ch.Cleanup(); err != nil {\n\t\tch.Error(\"cleanup on monitor exit: %s\", err.Error())\n\t}\n\n\t// Log error if it's not a context cancellation\n\tif err != nil && !errors.Is(err, context.Canceled) {\n\t\tch.Error(\"record stream: %s\", err.Error())\n\t}\n}\n\n// Update sends an update signal to the channel's update channel.\n// This notifies the Server-sent Event to boradcast the channel information to the client.\nfunc (ch *Channel) Update() {\n\tch.UpdateCh <- true\n}\n\n// RecordStream records the stream of the channel using the provided client.\n// It retrieves the stream information and starts watching the segments.\nfunc (ch *Channel) RecordStream(ctx context.Context, client *chaturbate.Client) error {\n\tstream, err := client.GetStream(ctx, ch.Config.Username)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"get stream: %w\", err)\n\t}\n\tch.StreamedAt = time.Now().Unix()\n\tch.Sequence = 0\n\n\tif err := ch.NextFile(); err != nil {\n\t\treturn fmt.Errorf(\"next file: %w\", err)\n\t}\n\n\t// Ensure file is cleaned up when this function exits in any case\n\tdefer func() {\n\t\tif err := ch.Cleanup(); err != nil {\n\t\t\tch.Error(\"cleanup on record stream exit: %s\", err.Error())\n\t\t}\n\t}()\n\n\tplaylist, err := stream.GetPlaylist(ctx, ch.Config.Resolution, ch.Config.Framerate)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"get playlist: %w\", err)\n\t}\n\tch.UpdateOnlineStatus(true) // Update online status after `GetPlaylist` is OK\n\n\tch.Info(\"stream quality - resolution %dp (target: %dp), framerate %dfps (target: %dfps)\", playlist.Resolution, ch.Config.Resolution, playlist.Framerate, ch.Config.Framerate)\n\n\treturn playlist.WatchSegments(ctx, ch.HandleSegment)\n}\n\n// HandleSegment processes and writes segment data to a file.\nfunc (ch *Channel) HandleSegment(b []byte, duration float64) error {\n\tif ch.Config.IsPaused {\n\t\treturn retry.Unrecoverable(internal.ErrPaused)\n\t}\n\n\tn, err := ch.File.Write(b)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"write file: %w\", err)\n\t}\n\n\tch.Filesize += n\n\tch.Duration += duration\n\tch.Info(\"duration: %s, filesize: %s\", internal.FormatDuration(ch.Duration), internal.FormatFilesize(ch.Filesize))\n\n\t// Send an SSE update to update the view\n\tch.Update()\n\n\tif ch.ShouldSwitchFile() {\n\t\tif err := ch.NextFile(); err != nil {\n\t\t\treturn fmt.Errorf(\"next file: %w\", err)\n\t\t}\n\t\tch.Info(\"max filesize or duration exceeded, new file created: %s\", ch.File.Name())\n\t\treturn nil\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "chaturbate/chaturbate.go",
    "content": "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\"github.com/avast/retry-go/v4\"\n\t\"github.com/grafov/m3u8\"\n\t\"github.com/samber/lo\"\n\t\"github.com/teacat/chaturbate-dvr/internal\"\n\t\"github.com/teacat/chaturbate-dvr/server\"\n)\n\n// roomDossierRegexp is used to extract the room dossier information from the HTML response.\nvar roomDossierRegexp = regexp.MustCompile(`window\\.initialRoomDossier = \"(.*?)\"`)\n\n// Client represents an API client for interacting with Chaturbate.\ntype Client struct {\n\tReq *internal.Req\n}\n\n// NewClient initializes and returns a new Client instance.\nfunc NewClient() *Client {\n\treturn &Client{\n\t\tReq: internal.NewReq(),\n\t}\n}\n\n// GetStream fetches the stream information for a given username.\nfunc (c *Client) GetStream(ctx context.Context, username string) (*Stream, error) {\n\treturn FetchStream(ctx, c.Req, username)\n}\n\n// FetchStream retrieves the streaming data from the given username's page.\nfunc FetchStream(ctx context.Context, client *internal.Req, username string) (*Stream, error) {\n\tbody, err := client.Get(ctx, fmt.Sprintf(\"%s%s\", server.Config.Domain, username))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get page body: %w\", err)\n\t}\n\n\t// Ensure that the playlist.m3u8 file is present in the response\n\tif !strings.Contains(body, \"playlist.m3u8\") {\n\t\treturn nil, internal.ErrChannelOffline\n\t}\n\n\treturn ParseStream(body)\n}\n\n// ParseStream extracts the HLS source URL from the given page body.\nfunc ParseStream(body string) (*Stream, error) {\n\tmatches := roomDossierRegexp.FindStringSubmatch(body)\n\tif len(matches) == 0 {\n\t\treturn nil, errors.New(\"room dossier not found\")\n\t}\n\n\t// Decode Unicode escape sequences in the extracted JSON string\n\tsourceData, err := strconv.Unquote(strings.Replace(strconv.Quote(matches[1]), `\\\\u`, `\\u`, -1))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode unicode: %w\", err)\n\t}\n\n\t// Unmarshal JSON to extract HLS source URL\n\tvar room struct {\n\t\tHLSSource string `json:\"hls_source\"`\n\t}\n\tif err := json.Unmarshal([]byte(sourceData), &room); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse JSON: %w\", err)\n\t}\n\n\treturn &Stream{HLSSource: room.HLSSource}, nil\n}\n\n// Stream represents an HLS stream source.\ntype Stream struct {\n\tHLSSource string\n}\n\n// GetPlaylist retrieves the playlist corresponding to the given resolution and framerate.\nfunc (s *Stream) GetPlaylist(ctx context.Context, resolution, framerate int) (*Playlist, error) {\n\treturn FetchPlaylist(ctx, s.HLSSource, resolution, framerate)\n}\n\n// FetchPlaylist fetches and decodes the HLS playlist file.\nfunc FetchPlaylist(ctx context.Context, hlsSource string, resolution, framerate int) (*Playlist, error) {\n\tif hlsSource == \"\" {\n\t\treturn nil, errors.New(\"HLS source is empty\")\n\t}\n\n\tresp, err := internal.NewReq().Get(ctx, hlsSource)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to fetch HLS source: %w\", err)\n\t}\n\n\treturn ParsePlaylist(resp, hlsSource, resolution, framerate)\n}\n\n// ParsePlaylist decodes the M3U8 playlist and extracts the variant streams.\nfunc ParsePlaylist(resp, hlsSource string, resolution, framerate int) (*Playlist, error) {\n\tp, _, err := m3u8.DecodeFrom(strings.NewReader(resp), true)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode m3u8 playlist: %w\", err)\n\t}\n\n\tmasterPlaylist, ok := p.(*m3u8.MasterPlaylist)\n\tif !ok {\n\t\treturn nil, errors.New(\"invalid master playlist format\")\n\t}\n\n\treturn PickPlaylist(masterPlaylist, hlsSource, resolution, framerate)\n}\n\n// Playlist represents an HLS playlist containing variant streams.\ntype Playlist struct {\n\tPlaylistURL string\n\tRootURL     string\n\tResolution  int\n\tFramerate   int\n}\n\n// Resolution represents a video resolution and its corresponding framerate.\ntype Resolution struct {\n\tFramerate map[int]string // [framerate]url\n\tWidth     int\n}\n\n// PickPlaylist selects the best matching variant stream based on resolution and framerate.\nfunc PickPlaylist(masterPlaylist *m3u8.MasterPlaylist, baseURL string, resolution, framerate int) (*Playlist, error) {\n\tresolutions := map[int]*Resolution{}\n\n\t// Extract available resolutions and framerates from the master playlist\n\tfor _, v := range masterPlaylist.Variants {\n\t\tparts := strings.Split(v.Resolution, \"x\")\n\t\tif len(parts) != 2 {\n\t\t\tcontinue\n\t\t}\n\t\twidth, err := strconv.Atoi(parts[1])\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"parse resolution: %w\", err)\n\t\t}\n\t\tframerateVal := 30\n\t\tif strings.Contains(v.Name, \"FPS:60.0\") {\n\t\t\tframerateVal = 60\n\t\t}\n\t\tif _, exists := resolutions[width]; !exists {\n\t\t\tresolutions[width] = &Resolution{Framerate: map[int]string{}, Width: width}\n\t\t}\n\t\tresolutions[width].Framerate[framerateVal] = v.URI\n\t}\n\n\t// Find exact match for requested resolution\n\tvariant, exists := resolutions[resolution]\n\tif !exists {\n\t\t// Filter resolutions below the requested resolution\n\t\tcandidates := lo.Filter(lo.Values(resolutions), func(r *Resolution, _ int) bool {\n\t\t\treturn r.Width < resolution\n\t\t})\n\t\t// Pick the highest resolution among the candidates\n\t\tvariant = lo.MaxBy(candidates, func(a, b *Resolution) bool {\n\t\t\treturn a.Width > b.Width\n\t\t})\n\t}\n\tif variant == nil {\n\t\treturn nil, fmt.Errorf(\"resolution not found\")\n\t}\n\n\tvar (\n\t\tfinalResolution = variant.Width\n\t\tfinalFramerate  = framerate\n\t)\n\t// Select the desired framerate, or fallback to the first available framerate\n\tplaylistURL, exists := variant.Framerate[framerate]\n\tif !exists {\n\t\tfor fr, url := range variant.Framerate {\n\t\t\tplaylistURL = url\n\t\t\tfinalFramerate = fr\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn &Playlist{\n\t\tPlaylistURL: strings.TrimSuffix(baseURL, \"playlist.m3u8\") + playlistURL,\n\t\tRootURL:     strings.TrimSuffix(baseURL, \"playlist.m3u8\"),\n\t\tResolution:  finalResolution,\n\t\tFramerate:   finalFramerate,\n\t}, nil\n}\n\n// WatchHandler is a function type that processes video segments.\ntype WatchHandler func(b []byte, duration float64) error\n\n// WatchSegments continuously fetches and processes video segments.\nfunc (p *Playlist) WatchSegments(ctx context.Context, handler WatchHandler) error {\n\tvar (\n\t\tclient  = internal.NewReq()\n\t\tlastSeq = -1\n\t)\n\n\tfor {\n\t\t// Fetch the latest playlist\n\t\tresp, err := client.Get(ctx, p.PlaylistURL)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"get playlist: %w\", err)\n\t\t}\n\t\tpl, _, err := m3u8.DecodeFrom(strings.NewReader(resp), true)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode from: %w\", err)\n\t\t}\n\t\tplaylist, ok := pl.(*m3u8.MediaPlaylist)\n\t\tif !ok {\n\t\t\treturn fmt.Errorf(\"cast to media playlist\")\n\t\t}\n\n\t\t// Process new segments\n\t\tfor _, v := range playlist.Segments {\n\t\t\tif v == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseq := internal.SegmentSeq(v.URI)\n\t\t\tif seq == -1 || seq <= lastSeq {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tlastSeq = seq\n\n\t\t\t// Fetch segment data with retry mechanism\n\t\t\tpipeline := func() ([]byte, error) {\n\t\t\t\treturn client.GetBytes(ctx, fmt.Sprintf(\"%s%s\", p.RootURL, v.URI))\n\t\t\t}\n\n\t\t\tresp, err := retry.DoWithData(\n\t\t\t\tpipeline,\n\t\t\t\tretry.Context(ctx),\n\t\t\t\tretry.Attempts(3),\n\t\t\t\tretry.Delay(600*time.Millisecond),\n\t\t\t\tretry.DelayType(retry.FixedDelay),\n\t\t\t)\n\t\t\tif err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// Process the segment using the provided handler\n\t\t\tif err := handler(resp, v.Duration); err != nil {\n\t\t\t\treturn fmt.Errorf(\"handler: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\t<-time.After(1 * time.Second) // time.Duration(playlist.TargetDuration)\n\t}\n}\n"
  },
  {
    "path": "config/config.go",
    "content": "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 new Config struct with values from the CLI context.\nfunc New(c *cli.Context) (*entity.Config, error) {\n\treturn &entity.Config{\n\t\tVersion:       c.App.Version,\n\t\tUsername:      c.String(\"username\"),\n\t\tAdminUsername: c.String(\"admin-username\"),\n\t\tAdminPassword: c.String(\"admin-password\"),\n\t\tFramerate:     c.Int(\"framerate\"),\n\t\tResolution:    c.Int(\"resolution\"),\n\t\tPattern:       c.String(\"pattern\"),\n\t\tMaxDuration:   c.Int(\"max-duration\"),\n\t\tMaxFilesize:   c.Int(\"max-filesize\"),\n\t\tPort:          c.String(\"port\"),\n\t\tInterval:      c.Int(\"interval\"),\n\t\tCookies:       c.String(\"cookies\"),\n\t\tUserAgent:     c.String(\"user-agent\"),\n\t\tDomain:        c.String(\"domain\"),\n\t}, nil\n}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: \"3.8\"\n\nservices:\n    chaturbate-dvr:\n        image: yamiodymel/chaturbate-dvr\n        container_name: chaturbate-dvr\n        ports:\n            - \"8080:8080\"\n        volumes:\n            - ./videos:/usr/src/app/videos\n            - ./conf:/usr/src/app/conf\n"
  },
  {
    "path": "entity/entity.go",
    "content": "package entity\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n)\n\n// Event represents the type of event for the channel.\ntype Event = string\n\nconst (\n\tEventUpdate Event = \"update\"\n\tEventLog    Event = \"log\"\n)\n\n// ChannelConfig represents the configuration for a channel.\ntype ChannelConfig struct {\n\tIsPaused    bool   `json:\"is_paused\"`\n\tUsername    string `json:\"username\"`\n\tFramerate   int    `json:\"framerate\"`\n\tResolution  int    `json:\"resolution\"`\n\tPattern     string `json:\"pattern\"`\n\tMaxDuration int    `json:\"max_duration\"`\n\tMaxFilesize int    `json:\"max_filesize\"`\n\tCreatedAt   int64  `json:\"created_at\"`\n}\n\nfunc (c *ChannelConfig) Sanitize() {\n\tc.Username = regexp.MustCompile(`[^a-zA-Z0-9_-]`).ReplaceAllString(c.Username, \"\")\n\tc.Username = strings.TrimSpace(c.Username)\n}\n\n// ChannelInfo represents the information about a channel,\n// mostly used for the template rendering.\ntype ChannelInfo struct {\n\tIsOnline     bool\n\tIsPaused     bool\n\tUsername     string\n\tDuration     string\n\tFilesize     string\n\tFilename     string\n\tStreamedAt   string\n\tMaxDuration  string\n\tMaxFilesize  string\n\tCreatedAt    int64\n\tLogs         []string\n\tGlobalConfig *Config // for nested template to access $.Config\n}\n\n// Config holds the configuration for the application.\ntype Config struct {\n\tVersion       string\n\tUsername      string\n\tAdminUsername string\n\tAdminPassword string\n\tFramerate     int\n\tResolution    int\n\tPattern       string\n\tMaxDuration   int\n\tMaxFilesize   int\n\tPort          string\n\tInterval      int\n\tCookies       string\n\tUserAgent     string\n\tDomain        string\n}\n"
  },
  {
    "path": "go.mod",
    "content": "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/gin v1.10.0\n\tgithub.com/grafov/m3u8 v0.12.1\n\tgithub.com/r3labs/sse/v2 v2.10.0\n\tgithub.com/samber/lo v1.49.1\n\tgithub.com/urfave/cli/v2 v2.27.6\n)\n\nrequire (\n\tgithub.com/bytedance/sonic v1.11.6 // indirect\n\tgithub.com/bytedance/sonic/loader v0.1.1 // indirect\n\tgithub.com/cloudwego/base64x v0.1.4 // indirect\n\tgithub.com/cloudwego/iasm v0.2.0 // indirect\n\tgithub.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.3 // indirect\n\tgithub.com/gin-contrib/sse v0.1.0 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/go-playground/validator/v10 v10.20.0 // indirect\n\tgithub.com/goccy/go-json v0.10.2 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.2.7 // indirect\n\tgithub.com/leodido/go-urn v1.4.0 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.2 // indirect\n\tgithub.com/russross/blackfriday/v2 v2.1.0 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.2.12 // indirect\n\tgithub.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect\n\tgolang.org/x/arch v0.8.0 // indirect\n\tgolang.org/x/crypto v0.36.0 // indirect\n\tgolang.org/x/net v0.38.0 // indirect\n\tgolang.org/x/sys v0.31.0 // indirect\n\tgolang.org/x/text v0.23.0 // indirect\n\tgoogle.golang.org/protobuf v1.34.1 // indirect\n\tgopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=\ngithub.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA=\ngithub.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=\ngithub.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=\ngithub.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=\ngithub.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=\ngithub.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=\ngithub.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=\ngithub.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=\ngithub.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=\ngithub.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=\ngithub.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=\ngithub.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=\ngithub.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=\ngithub.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=\ngithub.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=\ngithub.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=\ngithub.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=\ngithub.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=\ngithub.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=\ngithub.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=\ngithub.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=\ngithub.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s=\ngithub.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=\ngithub.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=\ngithub.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=\ngithub.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=\ngithub.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=\ngithub.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=\ngithub.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=\ngithub.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0=\ngithub.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I=\ngithub.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=\ngithub.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=\ngithub.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=\ngithub.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=\ngithub.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=\ngithub.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=\ngithub.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=\ngithub.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=\ngolang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=\ngolang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=\ngolang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=\ngolang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=\ngolang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=\ngolang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=\ngolang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=\ngolang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=\ngoogle.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=\ngopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y=\ngopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=\ngopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nnullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=\nrsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=\n"
  },
  {
    "path": "internal/internal.go",
    "content": "package internal\n\nimport (\n\t\"fmt\"\n\t\"regexp\"\n\t\"strconv\"\n)\n\n// FormatDuration converts a float64 duration (in seconds) to h:m:s format.\nfunc FormatDuration(duration float64) string {\n\tif duration == 0 {\n\t\treturn \"\"\n\t}\n\tvar (\n\t\thours   = int(duration) / 3600\n\t\tminutes = (int(duration) % 3600) / 60\n\t\tseconds = int(duration) % 60\n\t)\n\treturn fmt.Sprintf(\"%d:%02d:%02d\", hours, minutes, seconds)\n}\n\n// FormatFilesize converts an int filesize in bytes to a human-readable string (KB, MB, GB).\nfunc FormatFilesize(filesize int) string {\n\tif filesize == 0 {\n\t\treturn \"\"\n\t}\n\tconst (\n\t\tKB = 1024\n\t\tMB = KB * 1024\n\t\tGB = MB * 1024\n\t)\n\tswitch {\n\tcase filesize >= GB:\n\t\treturn fmt.Sprintf(\"%.2f GB\", float64(filesize)/float64(GB))\n\tcase filesize >= MB:\n\t\treturn fmt.Sprintf(\"%.2f MB\", float64(filesize)/float64(MB))\n\tcase filesize >= KB:\n\t\treturn fmt.Sprintf(\"%.2f KB\", float64(filesize)/float64(KB))\n\tdefault:\n\t\treturn fmt.Sprintf(\"%d bytes\", filesize)\n\t}\n}\n\n// SegmentSeq extracts the segment sequence number from a filename.\nfunc SegmentSeq(filename string) int {\n\tre := regexp.MustCompile(`_(\\d+)\\.ts$`)\n\tmatch := re.FindStringSubmatch(filename)\n\n\tif len(match) > 1 {\n\t\tnumber, err := strconv.Atoi(match[1])\n\t\tif err == nil {\n\t\t\treturn number\n\t\t}\n\t}\n\treturn -1\n}\n"
  },
  {
    "path": "internal/internal_err.go",
    "content": "package internal\n\nimport \"errors\"\n\nvar (\n\tErrChannelExists     = errors.New(\"channel exists\")\n\tErrChannelNotFound   = errors.New(\"channel not found\")\n\tErrCloudflareBlocked = errors.New(\"blocked by Cloudflare; try with `-cookies` and `-user-agent`\")\n\tErrAgeVerification   = errors.New(\"age verification required; try with `-cookies` and `-user-agent`\")\n\tErrChannelOffline    = errors.New(\"channel offline\")\n\tErrPrivateStream     = errors.New(\"channel went offline or private\")\n\tErrPaused            = errors.New(\"channel paused\")\n\tErrStopped           = errors.New(\"channel stopped\")\n)\n"
  },
  {
    "path": "internal/internal_req.go",
    "content": "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/chaturbate-dvr/server\"\n)\n\n// Req represents an HTTP client with customized settings.\ntype Req struct {\n\tclient *http.Client\n}\n\n// NewReq creates a new HTTP client with specific transport configurations.\nfunc NewReq() *Req {\n\treturn &Req{\n\t\tclient: &http.Client{\n\t\t\tTransport: CreateTransport(),\n\t\t},\n\t}\n}\n\n// CreateTransport initializes a custom HTTP transport.\nfunc CreateTransport() *http.Transport {\n\t// The DefaultTransport allows user changes the proxy settings via environment variables\n\t// such as HTTP_PROXY, HTTPS_PROXY.\n\tdefaultTransport := http.DefaultTransport.(*http.Transport)\n\n\tnewTransport := defaultTransport.Clone()\n\tnewTransport.TLSClientConfig = &tls.Config{\n\t\tInsecureSkipVerify: true,\n\t}\n\treturn newTransport\n}\n\n// Get sends an HTTP GET request and returns the response as a string.\nfunc (h *Req) Get(ctx context.Context, url string) (string, error) {\n\tresp, err := h.GetBytes(ctx, url)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"get bytes: %w\", err)\n\t}\n\treturn string(resp), nil\n}\n\n// GetBytes sends an HTTP GET request and returns the response as a byte slice.\nfunc (h *Req) GetBytes(ctx context.Context, url string) ([]byte, error) {\n\treq, cancel, err := CreateRequest(ctx, url)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"new request: %w\", err)\n\t}\n\tdefer cancel()\n\n\tresp, err := h.client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"client do: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tb, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read body: %w\", err)\n\t}\n\n\t// Check for Cloudflare protection\n\tif strings.Contains(string(b), \"<title>Just a moment...</title>\") {\n\t\treturn nil, ErrCloudflareBlocked\n\t}\n\t// Check for Age Verification\n\tif strings.Contains(string(b), \"Verify your age\") {\n\t\treturn nil, ErrAgeVerification\n\t}\n\n\tif resp.StatusCode == http.StatusForbidden {\n\t\treturn nil, fmt.Errorf(\"forbidden: %w\", ErrPrivateStream)\n\t}\n\n\treturn b, err\n}\n\n// CreateRequest constructs an HTTP GET request with necessary headers.\nfunc CreateRequest(ctx context.Context, url string) (*http.Request, context.CancelFunc, error) {\n\tctx, cancel := context.WithTimeout(ctx, 10*time.Second) // timed out after 10 seconds\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", url, nil)\n\tif err != nil {\n\t\treturn nil, cancel, err\n\t}\n\tSetRequestHeaders(req)\n\treturn req, cancel, nil\n}\n\n// SetRequestHeaders applies necessary headers to the request.\nfunc SetRequestHeaders(req *http.Request) {\n\treq.Header.Set(\"X-Requested-With\", \"XMLHttpRequest\") // So Cloudflare would likely accept the request, and no Age Verification\n\n\tif server.Config.UserAgent != \"\" {\n\t\treq.Header.Set(\"User-Agent\", server.Config.UserAgent)\n\t}\n\tif server.Config.Cookies != \"\" {\n\t\tcookies := ParseCookies(server.Config.Cookies)\n\t\tfor name, value := range cookies {\n\t\t\treq.AddCookie(&http.Cookie{Name: name, Value: value})\n\t\t}\n\t}\n}\n\n// ParseCookies converts a cookie string into a map.\nfunc ParseCookies(cookieStr string) map[string]string {\n\tcookies := make(map[string]string)\n\tpairs := strings.Split(cookieStr, \";\")\n\n\t// Iterate over each cookie pair and extract key-value pairs\n\tfor _, pair := range pairs {\n\t\tparts := strings.SplitN(strings.TrimSpace(pair), \"=\", 2)\n\t\tif len(parts) == 2 {\n\t\t\t// Trim spaces around key and value\n\t\t\tkey := strings.TrimSpace(parts[0])\n\t\t\tvalue := strings.TrimSpace(parts[1])\n\t\t\t// Store cookie name and value in the map\n\t\t\tcookies[key] = value\n\t\t}\n\t}\n\treturn cookies\n}\n"
  },
  {
    "path": "main.go",
    "content": "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-dvr/entity\"\n\t\"github.com/teacat/chaturbate-dvr/manager\"\n\t\"github.com/teacat/chaturbate-dvr/router\"\n\t\"github.com/teacat/chaturbate-dvr/server\"\n\t\"github.com/urfave/cli/v2\"\n)\n\nconst logo = `\n ██████╗██╗  ██╗ █████╗ ████████╗██╗   ██╗██████╗ ██████╗  █████╗ ████████╗███████╗\n██╔════╝██║  ██║██╔══██╗╚══██╔══╝██║   ██║██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██╔════╝\n██║     ███████║███████║   ██║   ██║   ██║██████╔╝██████╔╝███████║   ██║   █████╗\n██║     ██╔══██║██╔══██║   ██║   ██║   ██║██╔══██╗██╔══██╗██╔══██║   ██║   ██╔══╝\n╚██████╗██║  ██║██║  ██║   ██║   ╚██████╔╝██║  ██║██████╔╝██║  ██║   ██║   ███████╗\n ╚═════╝╚═╝  ╚═╝╚═╝  ╚═╝   ╚═╝    ╚═════╝ ╚═╝  ╚═╝╚═════╝ ╚═╝  ╚═╝   ╚═╝   ╚══════╝\n██████╗ ██╗   ██╗██████╗\n██╔══██╗██║   ██║██╔══██╗\n██║  ██║██║   ██║██████╔╝\n██║  ██║╚██╗ ██╔╝██╔══██╗\n██████╔╝ ╚████╔╝ ██║  ██║\n╚═════╝   ╚═══╝  ╚═╝  ╚═╝`\n\nfunc main() {\n\tapp := &cli.App{\n\t\tName:    \"chaturbate-dvr\",\n\t\tVersion: \"2.0.3\",\n\t\tUsage:   \"Record your favorite Chaturbate streams automatically. 😎🫵\",\n\t\tFlags: []cli.Flag{\n\t\t\t&cli.StringFlag{\n\t\t\t\tName:    \"username\",\n\t\t\t\tAliases: []string{\"u\"},\n\t\t\t\tUsage:   \"The username of the channel to record\",\n\t\t\t\tValue:   \"\",\n\t\t\t},\n\t\t\t&cli.StringFlag{\n\t\t\t\tName:  \"admin-username\",\n\t\t\t\tUsage: \"Username for web authentication (optional)\",\n\t\t\t\tValue: \"\",\n\t\t\t},\n\t\t\t&cli.StringFlag{\n\t\t\t\tName:  \"admin-password\",\n\t\t\t\tUsage: \"Password for web authentication (optional)\",\n\t\t\t\tValue: \"\",\n\t\t\t},\n\t\t\t&cli.IntFlag{\n\t\t\t\tName:  \"framerate\",\n\t\t\t\tUsage: \"Desired framerate (FPS)\",\n\t\t\t\tValue: 30,\n\t\t\t},\n\t\t\t&cli.IntFlag{\n\t\t\t\tName:  \"resolution\",\n\t\t\t\tUsage: \"Desired resolution (e.g., 1080 for 1080p)\",\n\t\t\t\tValue: 1080,\n\t\t\t},\n\t\t\t&cli.StringFlag{\n\t\t\t\tName:  \"pattern\",\n\t\t\t\tUsage: \"Template for naming recorded videos\",\n\t\t\t\tValue: \"videos/{{.Username}}_{{.Year}}-{{.Month}}-{{.Day}}_{{.Hour}}-{{.Minute}}-{{.Second}}{{if .Sequence}}_{{.Sequence}}{{end}}\",\n\t\t\t},\n\t\t\t&cli.IntFlag{\n\t\t\t\tName:  \"max-duration\",\n\t\t\t\tUsage: \"Split video into segments every N minutes ('0' to disable)\",\n\t\t\t\tValue: 0,\n\t\t\t},\n\t\t\t&cli.IntFlag{\n\t\t\t\tName:  \"max-filesize\",\n\t\t\t\tUsage: \"Split video into segments every N MB ('0' to disable)\",\n\t\t\t\tValue: 0,\n\t\t\t},\n\t\t\t&cli.StringFlag{\n\t\t\t\tName:    \"port\",\n\t\t\t\tAliases: []string{\"p\"},\n\t\t\t\tUsage:   \"Port for the web interface and API\",\n\t\t\t\tValue:   \"8080\",\n\t\t\t},\n\t\t\t&cli.IntFlag{\n\t\t\t\tName:  \"interval\",\n\t\t\t\tUsage: \"Check if the channel is online every N minutes\",\n\t\t\t\tValue: 1,\n\t\t\t},\n\t\t\t&cli.StringFlag{\n\t\t\t\tName:  \"cookies\",\n\t\t\t\tUsage: \"Cookies to use in the request (format: key=value; key2=value2)\",\n\t\t\t\tValue: \"\",\n\t\t\t},\n\t\t\t&cli.StringFlag{\n\t\t\t\tName:  \"user-agent\",\n\t\t\t\tUsage: \"Custom User-Agent for the request\",\n\t\t\t\tValue: \"\",\n\t\t\t},\n\t\t\t&cli.StringFlag{\n\t\t\t\tName:  \"domain\",\n\t\t\t\tUsage: \"Chaturbate domain to use\",\n\t\t\t\tValue: \"https://chaturbate.com/\",\n\t\t\t},\n\t\t},\n\t\tAction: start,\n\t}\n\tif err := app.Run(os.Args); err != nil {\n\t\tlog.Fatal(err)\n\t}\n}\n\nfunc start(c *cli.Context) error {\n\tfmt.Println(logo)\n\n\tvar err error\n\tserver.Config, err = config.New(c)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"new config: %w\", err)\n\t}\n\tserver.Manager, err = manager.New()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"new manager: %w\", err)\n\t}\n\n\t// init web interface if username is not provided\n\tif server.Config.Username == \"\" {\n\t\tfmt.Printf(\"👋 Visit http://localhost:%s to use the Web UI\\n\\n\\n\", c.String(\"port\"))\n\n\t\tif err := server.Manager.LoadConfig(); err != nil {\n\t\t\treturn fmt.Errorf(\"load config: %w\", err)\n\t\t}\n\n\t\treturn router.SetupRouter().Run(\":\" + c.String(\"port\"))\n\t}\n\n\t// else create a channel with the provided username\n\tif err := server.Manager.CreateChannel(&entity.ChannelConfig{\n\t\tIsPaused:    false,\n\t\tUsername:    c.String(\"username\"),\n\t\tFramerate:   c.Int(\"framerate\"),\n\t\tResolution:  c.Int(\"resolution\"),\n\t\tPattern:     c.String(\"pattern\"),\n\t\tMaxDuration: c.Int(\"max-duration\"),\n\t\tMaxFilesize: c.Int(\"max-filesize\"),\n\t}, false); err != nil {\n\t\treturn fmt.Errorf(\"create channel: %w\", err)\n\t}\n\n\t// block forever\n\tselect {}\n}\n"
  },
  {
    "path": "manager/manager.go",
    "content": "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/r3labs/sse/v2\"\n\t\"github.com/teacat/chaturbate-dvr/channel\"\n\t\"github.com/teacat/chaturbate-dvr/entity\"\n\t\"github.com/teacat/chaturbate-dvr/router/view\"\n)\n\n// Manager is responsible for managing channels and their states.\ntype Manager struct {\n\tChannels sync.Map\n\tSSE      *sse.Server\n}\n\n// New initializes a new Manager instance with an SSE server.\nfunc New() (*Manager, error) {\n\n\tserver := sse.New()\n\tserver.SplitData = true\n\n\tupdateStream := server.CreateStream(\"updates\")\n\tupdateStream.AutoReplay = false\n\n\treturn &Manager{\n\t\tSSE: server,\n\t}, nil\n}\n\n// SaveConfig saves the current channels and state to a JSON file.\nfunc (m *Manager) SaveConfig() error {\n\tvar config []*entity.ChannelConfig\n\n\tm.Channels.Range(func(key, value any) bool {\n\t\tconfig = append(config, value.(*channel.Channel).Config)\n\t\treturn true\n\t})\n\n\tb, err := json.MarshalIndent(config, \"\", \"  \")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"marshal: %w\", err)\n\t}\n\tif err := os.MkdirAll(\"./conf\", 0777); err != nil {\n\t\treturn fmt.Errorf(\"mkdir all conf: %w\", err)\n\t}\n\tif err := os.WriteFile(\"./conf/channels.json\", b, 0777); err != nil {\n\t\treturn fmt.Errorf(\"write file: %w\", err)\n\t}\n\treturn nil\n}\n\n// LoadConfig loads the channels from JSON and starts them.\nfunc (m *Manager) LoadConfig() error {\n\tb, err := os.ReadFile(\"./conf/channels.json\")\n\tif os.IsNotExist(err) {\n\t\treturn nil\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"read file: %w\", err)\n\t}\n\n\tvar config []*entity.ChannelConfig\n\tif err := json.Unmarshal(b, &config); err != nil {\n\t\treturn fmt.Errorf(\"unmarshal: %w\", err)\n\t}\n\n\tfor i, conf := range config {\n\t\tch := channel.New(conf)\n\t\tm.Channels.Store(conf.Username, ch)\n\n\t\tif ch.Config.IsPaused {\n\t\t\tch.Info(\"channel was paused, waiting for resume\")\n\t\t\tcontinue\n\t\t}\n\t\tgo ch.Resume(i)\n\t}\n\treturn nil\n}\n\n// CreateChannel starts monitoring an M3U8 stream\nfunc (m *Manager) CreateChannel(conf *entity.ChannelConfig, shouldSave bool) error {\n\tconf.Sanitize()\n\tch := channel.New(conf)\n\n\t// prevent duplicate channels\n\t_, ok := m.Channels.Load(conf.Username)\n\tif ok {\n\t\treturn fmt.Errorf(\"channel %s already exists\", conf.Username)\n\t}\n\tm.Channels.Store(conf.Username, ch)\n\n\tgo ch.Resume(0)\n\n\tif shouldSave {\n\t\tif err := m.SaveConfig(); err != nil {\n\t\t\treturn fmt.Errorf(\"save config: %w\", err)\n\t\t}\n\t}\n\treturn nil\n}\n\n// StopChannel stops the channel.\nfunc (m *Manager) StopChannel(username string) error {\n\tthing, ok := m.Channels.Load(username)\n\tif !ok {\n\t\treturn nil\n\t}\n\tthing.(*channel.Channel).Stop()\n\tm.Channels.Delete(username)\n\n\tif err := m.SaveConfig(); err != nil {\n\t\treturn fmt.Errorf(\"save config: %w\", err)\n\t}\n\treturn nil\n}\n\n// PauseChannel pauses the channel.\nfunc (m *Manager) PauseChannel(username string) error {\n\tthing, ok := m.Channels.Load(username)\n\tif !ok {\n\t\treturn nil\n\t}\n\tthing.(*channel.Channel).Pause()\n\n\tif err := m.SaveConfig(); err != nil {\n\t\treturn fmt.Errorf(\"save config: %w\", err)\n\t}\n\treturn nil\n}\n\n// ResumeChannel resumes the channel.\nfunc (m *Manager) ResumeChannel(username string) error {\n\tthing, ok := m.Channels.Load(username)\n\tif !ok {\n\t\treturn nil\n\t}\n\tthing.(*channel.Channel).Resume(0)\n\n\tif err := m.SaveConfig(); err != nil {\n\t\treturn fmt.Errorf(\"save config: %w\", err)\n\t}\n\treturn nil\n}\n\n// ChannelInfo returns a list of channel information for the web UI.\nfunc (m *Manager) ChannelInfo() []*entity.ChannelInfo {\n\tvar channels []*entity.ChannelInfo\n\n\t// Iterate over the channels and append their information to the slice\n\tm.Channels.Range(func(key, value any) bool {\n\t\tchannels = append(channels, value.(*channel.Channel).ExportInfo())\n\t\treturn true\n\t})\n\n\tsort.Slice(channels, func(i, j int) bool {\n\t\t// First priority: Online channels\n\t\tif channels[i].IsOnline != channels[j].IsOnline {\n\t\t\treturn channels[i].IsOnline\n\t\t}\n\t\t// Second priority: Alphabetical order by username\n\t\treturn strings.ToLower(channels[i].Username) < strings.ToLower(channels[j].Username)\n\t})\n\n\treturn channels\n}\n\n// Publish sends an SSE event to the specified channel.\nfunc (m *Manager) Publish(evt entity.Event, info *entity.ChannelInfo) {\n\tswitch evt {\n\tcase entity.EventUpdate:\n\t\tvar b bytes.Buffer\n\t\tif err := view.InfoTpl.ExecuteTemplate(&b, \"channel_info\", info); err != nil {\n\t\t\tfmt.Println(\"Error executing template:\", err)\n\t\t\treturn\n\t\t}\n\t\tm.SSE.Publish(\"updates\", &sse.Event{\n\t\t\tEvent: []byte(info.Username + \"-info\"),\n\t\t\tData:  b.Bytes(),\n\t\t})\n\tcase entity.EventLog:\n\t\tm.SSE.Publish(\"updates\", &sse.Event{\n\t\t\tEvent: []byte(info.Username + \"-log\"),\n\t\t\tData:  []byte(strings.Join(info.Logs, \"\\n\")),\n\t\t})\n\t}\n}\n\n// Subscriber handles SSE subscriptions for the specified channel.\nfunc (m *Manager) Subscriber(w http.ResponseWriter, r *http.Request) {\n\tm.SSE.ServeHTTP(w, r)\n}\n"
  },
  {
    "path": "router/router.go",
    "content": "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/teacat/chaturbate-dvr/router/view\"\n\t\"github.com/teacat/chaturbate-dvr/server\"\n)\n\n// SetupRouter initializes and returns the Gin router.\nfunc SetupRouter() *gin.Engine {\n\tgin.SetMode(gin.ReleaseMode)\n\n\tr := gin.Default()\n\tif err := LoadHTMLFromEmbedFS(r, view.FS, \"templates/index.html\", \"templates/channel_info.html\"); err != nil {\n\t\tlog.Fatalf(\"failed to load HTML templates: %v\", err)\n\t}\n\n\t// Apply authentication if configured\n\tSetupAuth(r)\n\t// Serve static frontend files\n\tSetupStatic(r)\n\t// Register views\n\tSetupViews(r)\n\n\treturn r\n}\n\n// SetupAuth applies basic authentication if credentials are provided.\nfunc SetupAuth(r *gin.Engine) {\n\tif server.Config.AdminUsername != \"\" && server.Config.AdminPassword != \"\" {\n\t\tauth := gin.BasicAuth(gin.Accounts{\n\t\t\tserver.Config.AdminUsername: server.Config.AdminPassword,\n\t\t})\n\t\tr.Use(auth)\n\t}\n}\n\n// SetupStatic serves static frontend files.\nfunc SetupStatic(r *gin.Engine) {\n\tfs, err := view.StaticFS()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to initialize static files: %v\", err)\n\t}\n\tr.StaticFS(\"/static\", fs)\n}\n\n// setupViews registers HTML templates and view handlers.\nfunc SetupViews(r *gin.Engine) {\n\tr.GET(\"/\", Index)\n\tr.GET(\"/updates\", Updates)\n\tr.POST(\"/update_config\", UpdateConfig)\n\tr.POST(\"/create_channel\", CreateChannel)\n\tr.POST(\"/stop_channel/:username\", StopChannel)\n\tr.POST(\"/pause_channel/:username\", PauseChannel)\n\tr.POST(\"/resume_channel/:username\", ResumeChannel)\n\n}\n\n// LoadHTMLFromEmbedFS loads specific HTML templates from an embedded filesystem and registers them with Gin.\nfunc LoadHTMLFromEmbedFS(r *gin.Engine, embeddedFS embed.FS, files ...string) error {\n\ttempl := template.New(\"\")\n\tfor _, file := range files {\n\t\tcontent, err := embeddedFS.ReadFile(file)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = templ.New(filepath.Base(file)).Parse(string(content))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Set the parsed templates as the HTML renderer for Gin\n\tr.SetHTMLTemplate(templ)\n\treturn nil\n}\n"
  },
  {
    "path": "router/router_handler.go",
    "content": "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/chaturbate-dvr/entity\"\n\t\"github.com/teacat/chaturbate-dvr/server\"\n)\n\n// IndexData represents the data structure for the index page.\ntype IndexData struct {\n\tConfig   *entity.Config\n\tChannels []*entity.ChannelInfo\n}\n\n// Index renders the index page with channel information.\nfunc Index(c *gin.Context) {\n\tc.HTML(200, \"index.html\", &IndexData{\n\t\tConfig:   server.Config,\n\t\tChannels: server.Manager.ChannelInfo(),\n\t})\n}\n\n// CreateChannelRequest represents the request body for creating a channel.\ntype CreateChannelRequest struct {\n\tUsername    string `form:\"username\" binding:\"required\"`\n\tFramerate   int    `form:\"framerate\" binding:\"required\"`\n\tResolution  int    `form:\"resolution\" binding:\"required\"`\n\tPattern     string `form:\"pattern\" binding:\"required\"`\n\tMaxDuration int    `form:\"max_duration\"`\n\tMaxFilesize int    `form:\"max_filesize\"`\n}\n\n// CreateChannel creates a new channel.\nfunc CreateChannel(c *gin.Context) {\n\tvar req *CreateChannelRequest\n\tif err := c.Bind(&req); err != nil {\n\t\tc.AbortWithError(http.StatusBadRequest, fmt.Errorf(\"bind: %w\", err))\n\t\treturn\n\t}\n\n\tfor _, username := range strings.Split(req.Username, \",\") {\n\t\tserver.Manager.CreateChannel(&entity.ChannelConfig{\n\t\t\tIsPaused:    false,\n\t\t\tUsername:    username,\n\t\t\tFramerate:   req.Framerate,\n\t\t\tResolution:  req.Resolution,\n\t\t\tPattern:     req.Pattern,\n\t\t\tMaxDuration: req.MaxDuration,\n\t\t\tMaxFilesize: req.MaxFilesize,\n\t\t\tCreatedAt:   time.Now().Unix(),\n\t\t}, true)\n\t}\n\tc.Redirect(http.StatusFound, \"/\")\n}\n\n// StopChannel stops a channel.\nfunc StopChannel(c *gin.Context) {\n\tserver.Manager.StopChannel(c.Param(\"username\"))\n\n\tc.Redirect(http.StatusFound, \"/\")\n}\n\n// PauseChannel pauses a channel.\nfunc PauseChannel(c *gin.Context) {\n\tserver.Manager.PauseChannel(c.Param(\"username\"))\n\n\tc.Redirect(http.StatusFound, \"/\")\n}\n\n// ResumeChannel resumes a paused channel.\nfunc ResumeChannel(c *gin.Context) {\n\tserver.Manager.ResumeChannel(c.Param(\"username\"))\n\n\tc.Redirect(http.StatusFound, \"/\")\n}\n\n// Updates handles the SSE connection for updates.\nfunc Updates(c *gin.Context) {\n\tserver.Manager.Subscriber(c.Writer, c.Request)\n}\n\n// UpdateConfigRequest represents the request body for updating configuration.\ntype UpdateConfigRequest struct {\n\tCookies   string `form:\"cookies\"`\n\tUserAgent string `form:\"user_agent\"`\n}\n\n// UpdateConfig updates the server configuration.\nfunc UpdateConfig(c *gin.Context) {\n\tvar req *UpdateConfigRequest\n\tif err := c.Bind(&req); err != nil {\n\t\tc.AbortWithError(http.StatusBadRequest, fmt.Errorf(\"bind: %w\", err))\n\t\treturn\n\t}\n\n\tserver.Config.Cookies = req.Cookies\n\tserver.Config.UserAgent = req.UserAgent\n\tc.Redirect(http.StatusFound, \"/\")\n}\n"
  },
  {
    "path": "router/view/templates/channel_info.html",
    "content": "{{ define \"channel_info\" }}\n\n<!-- Header -->\n<div class=\"ts-grid is-middle-aligned\">\n    <div class=\"column is-fluid\">\n        <div class=\"ts-header\">{{ .Username }}</div>\n    </div>\n    <div class=\"column\">\n        {{ if and .IsOnline (not .IsPaused) }}\n        <span class=\"ts-badge is-small is-start-spaced\">RECORDING</span>\n        {{ else if and (not .IsOnline) (not .IsPaused) }}\n        <span class=\"ts-badge is-secondary is-small is-start-spaced\">OFFLINE</span>\n        {{ else if .IsPaused }}\n        <span class=\"ts-badge is-negative is-small is-start-spaced\">PAUSED</span>\n        {{ end }}\n    </div>\n</div>\n<!-- / Header -->\n\n<div class=\"ts-divider has-top-spaced\"></div>\n\n<!-- Info: Channel URL -->\n<div class=\"ts-grid has-top-spaced\">\n    <div class=\"column\">\n        <span class=\"ts-icon is-link-icon\"></span>\n    </div>\n    <div class=\"column is-fluid\">\n        <div class=\"ts-text is-small is-bold\">Channel URL</div>\n        <a class=\"ts-text is-small is-link is-external-link\" href=\"{{ .GlobalConfig.Domain }}{{ .Username }}\" target=\"_blank\"> {{ .GlobalConfig.Domain }}{{ .Username }}</a>\n    </div>\n</div>\n<!-- / Info: Channel URL -->\n\n<!-- Info: Filename -->\n<div class=\"ts-grid has-top-spaced\">\n    <div class=\"column\">\n        <span class=\"ts-icon is-folder-icon\"></span>\n    </div>\n    <div class=\"column is-fluid\">\n        <div class=\"ts-text is-small is-bold\">Filename</div>\n        {{ if .Filename }}\n        <div class=\"ts-text is-description\">{{ .Filename }}</div>\n        {{ else }}\n        <span>-</span>\n        {{ end }}\n    </div>\n</div>\n<!-- / Info: Filename -->\n\n<!-- Info: Last streamed at -->\n<div class=\"ts-grid ts-grid has-top-spaced\">\n    <div class=\"column\">\n        <span class=\"ts-icon is-tower-broadcast-icon\"></span>\n    </div>\n    <div class=\"column is-fluid\">\n        <div class=\"ts-text is-small is-bold\">Last streamed at</div>\n        <div class=\"ts-text is-description\">{{ if .StreamedAt }}{{ .StreamedAt }} {{ if and .IsOnline (not .IsPaused) }}(NOW){{ end }}{{ else }} - {{ end }}</div>\n    </div>\n</div>\n<!-- / Info: Last streamed at -->\n\n<!-- Info: Segment duration -->\n<div class=\"ts-grid ts-grid has-top-spaced\">\n    <div class=\"column\">\n        <span class=\"ts-icon is-clock-icon\"></span>\n    </div>\n    <div class=\"column is-fluid\">\n        <div class=\"ts-text is-small is-bold\">Segment duration</div>\n        <div class=\"ts-text is-description\">{{ if .Duration }} {{ .Duration }} {{ if .MaxDuration }} / {{ .MaxDuration }} {{ end }} {{ else }} - {{ end }}</div>\n    </div>\n</div>\n<!-- / Info: Segment duration -->\n\n<!-- Info: Segment filesize -->\n<div class=\"ts-grid has-top-spaced\">\n    <div class=\"column\">\n        <span class=\"ts-icon is-chart-pie-icon\"></span>\n    </div>\n    <div class=\"column is-fluid\">\n        <div class=\"ts-text is-small is-bold\">Segment filesize</div>\n        <div class=\"ts-text is-description\">{{ if .Filesize }} {{ .Filesize }} {{ if .MaxFilesize }} / {{ .MaxFilesize }} {{ end }} {{ else }} - {{ end }}</div>\n    </div>\n</div>\n<!-- / Info: Segment filesize -->\n\n<!-- Actions -->\n<div class=\"ts-grid is-2-columns has-top-spaced-large\">\n    <div class=\"column\">\n        {{ if .IsPaused }}\n        <form>\n            <button class=\"ts-button is-start-icon is-fluid\" hx-post=\"/resume_channel/{{ .Username }}\" hx-swap=\"none\">\n                <span class=\"ts-icon is-play-icon\"></span>\n                Resume\n            </button>\n        </form>\n        {{ else }}\n        <form>\n            <button type=\"submit\" class=\"ts-button is-start-icon is-secondary is-fluid\" hx-post=\"/pause_channel/{{ .Username }}\" hx-swap=\"none\">\n                <span class=\"ts-icon is-pause-icon\"></span>\n                Pause\n            </button>\n        </form>\n        {{ end }}\n    </div>\n    <div class=\"column\">\n        <form action=\"/stop_channel/{{ .Username }}\" method=\"POST\" onsubmit=\"return confirm('Are you sure you want to delete `{{ .Username }}` channel?')\">\n            <button class=\"ts-button is-start-icon is-outlined is-negative is-fluid\" >\n                <span class=\"ts-icon is-trash-icon\"></span>\n                Delete\n            </button>\n        </form>\n    </div>\n</div>\n<!-- / Actions -->\n{{ end }}\n"
  },
  {
    "path": "router/view/templates/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\" class=\"is-secondary\">\n    <head>\n        <meta charset=\"UTF-8\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n        <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/tocas/5.0.1/tocas.min.css\" />\n        <script src=\"https://cdnjs.cloudflare.com/ajax/libs/tocas/5.0.1/tocas.min.js\"></script>\n        <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n        <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n        <link href=\"https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700&display=swap\" rel=\"stylesheet\" />\n        <script src=\"/static/scripts/htmx.min.js\" crossorigin=\"anonymous\"></script>\n        <script src=\"/static/scripts/sse.min.js\" crossorigin=\"anonymous\"></script>\n        <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/static/apple-touch-icon.png\">\n        <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/static/favicon-32x32.png\">\n        <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/static/favicon-16x16.png\">\n        <link rel=\"manifest\" href=\"/static/site.webmanifest\">\n        <title>Chaturbate DVR</title>\n    </head>\n\n    <body hx-ext=\"sse\">\n        <!-- Main Section -->\n        <div class=\"ts-container has-vertically-padded-big\" style=\"--width: 990px\">\n            <!-- Header -->\n            <div class=\"ts-grid is-bottom-aligned\">\n                <div class=\"column is-fluid\">\n                    <div class=\"ts-header is-huge is-uppercased is-heavy has-leading-small\">Chaturbate DVR</div>\n                    <div class=\"ts-meta\">\n                        <div class=\"item\">\n                            <span id=\"recording-counter\" class=\"ts-text is-description is-bold\"></span>\n                        </div>\n                        <div class=\"item\">\n                            <span class=\"ts-text is-description is-bold\">VERSION {{ .Config.Version }}</span>\n                        </div>\n                    </div>\n                </div>\n                <div class=\"column\">\n                    <button class=\"ts-button is-start-icon is-outlined\" data-dialog=\"settings-dialog\">\n                        <span class=\"ts-icon is-gear-icon\"></span>\n                        Settings\n                    </button>\n                </div>\n                <div class=\"column\">\n                    <button class=\"ts-button is-start-icon\" data-dialog=\"create-dialog\">\n                        <span class=\"ts-icon is-plus-icon\"></span>\n                        Add Channel\n                    </button>\n                </div>\n            </div>\n            <!-- / Header -->\n\n            {{ if not .Channels }}\n            <!-- Blankslate -->\n            <div class=\"ts-divider has-vertically-spaced-large\"></div>\n            <div class=\"ts-blankslate\">\n                <span class=\"ts-icon is-eye-low-vision-icon\"></span>\n                <div class=\"header\">No channels are currently recording</div>\n                <div class=\"description\">Add a new Chaturbate channel to start recording.</div>\n                <div class=\"action\">\n                    <button class=\"ts-button is-start-icon\" data-dialog=\"create-dialog\">\n                        <span class=\"ts-icon is-plus-icon\"></span>\n                        Add Channel\n                    </button>\n                </div>\n            </div>\n            <!-- / Blankslate -->\n            {{ else }}\n\n            <!-- Channels -->\n            <div class=\"ts-wrap is-vertical has-top-spaced-large\" sse-connect=\"/updates?stream=updates\">\n                {{ range .Channels }}\n                <div class=\"ts-box is-horizontal\">\n                     <!-- Info Section -->\n                     <div sse-swap=\"{{ .Username }}-info\" class=\"ts-content is-padded has-break-all\" style=\"width: 400px; line-height: 1.45; padding-right: 0\">\n                        {{ template \"channel_info\" . }}\n                    </div>\n                    <!-- / Info Section -->\n\n                    <!-- Log Section -->\n                    <div class=\"ts-content is-padded\" style=\"flex: 1; gap: 0.8rem; display: flex; flex-direction: column\">\n                        <div class=\"ts-input\" style=\"flex: 1\">\n                            <textarea class=\"has-full-height\" readonly sse-swap=\"{{ .Username }}-log\" style=\"scrollbar-width: thin\">{{ range .Logs }}{{ . }}&NewLine;{{ end }}</textarea>\n                        </div>\n                        <div>\n                            <label class=\"ts-switch is-small\" style=\"display: flex\">\n                                <input type=\"checkbox\" checked />\n                                Auto-Update & Scroll Logs\n                            </label>\n                        </div>\n                    </div>\n                    <!-- / Log Section -->\n                </div>\n                {{ end }}\n            </div>\n            <!-- / Channels -->\n            {{ end }}\n        </div>\n        <!-- / Main Section -->\n\n        <!-- Settings Dialog -->\n        <dialog id=\"settings-dialog\" class=\"ts-modal\" style=\"--width: 680px\">\n            <div class=\"content\">\n                <form action=\"/update_config\" method=\"POST\">\n                    <div class=\"ts-content is-horizontally-padded is-secondary\">\n                        <div class=\"ts-grid\">\n                            <div class=\"column is-fluid\">\n                                <div class=\"ts-header\">Settings</div>\n                            </div>\n                            <div class=\"column\">\n                                <button type=\"reset\" class=\"ts-close is-rounded is-large is-secondary\" data-dialog=\"settings-dialog\"></button>\n                            </div>\n                        </div>\n                    </div>\n\n                    <div class=\"ts-divider\"></div>\n\n                    <div class=\"ts-content is-vertically-padded\">\n                        <!-- Cookies -->\n                        <div class=\"ts-control is-wide\">\n                            <div class=\"label\">Cookies</div>\n                            <div class=\"content\">\n                                <div class=\"ts-input\">\n                                    <textarea name=\"cookies\" rows=\"5\">{{ .Config.Cookies }}</textarea>\n                                </div>\n                                <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>\n                            </div>\n                        </div>\n                        <!-- / Cookies -->\n\n                        <!-- User Agent -->\n                        <div class=\"ts-control is-wide has-top-spaced-large\">\n                            <div class=\"label\">User Agent</div>\n                            <div class=\"content\">\n                                <div class=\"ts-input\">\n                                    <textarea name=\"user_agent\" rows=\"5\">{{ .Config.UserAgent }}</textarea>\n                                </div>\n                                <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>\n                            </div>\n                        </div>\n                        <!-- / User Agent -->\n                    </div>\n\n                    <div class=\"ts-divider\"></div>\n\n                    <div class=\"ts-content is-secondary is-horizontally-padded\">\n                        <div class=\"ts-grid is-middle-aligned\">\n                            <div class=\"column is-fluid\">\n                                <div class=\"ts-text is-description\">\n                                    <span class=\"ts-icon is-triangle-exclamation-icon is-end-spaced\"></span>\n                                    Changes will be reverted after the program restarts\n                                </div>\n                            </div>\n                            <div class=\"column\">\n                                <button type=\"reset\" class=\"ts-button is-outlined is-secondary\" data-dialog=\"settings-dialog\">Cancel</button>\n                            </div>\n                            <div class=\"column\">\n                                <button type=\"submit\" class=\"ts-button is-primary\">Apply</button>\n                            </div>\n                        </div>\n                    </div>\n                </form>\n            </div>\n        </dialog>\n        <!-- / Settings Dialog -->\n\n        <!-- Create Dialog -->\n        <dialog id=\"create-dialog\" class=\"ts-modal\" style=\"--width: 680px\">\n            <div class=\"content\">\n                <form action=\"/create_channel\" method=\"POST\">\n                    <div class=\"ts-content is-horizontally-padded is-secondary\">\n                        <div class=\"ts-grid\">\n                            <div class=\"column is-fluid\">\n                                <div class=\"ts-header\">Add Channel</div>\n                            </div>\n                            <div class=\"column\">\n                                <button type=\"reset\" class=\"ts-close is-rounded is-large is-secondary\" data-dialog=\"create-dialog\"></button>\n                            </div>\n                        </div>\n                    </div>\n\n                    <div class=\"ts-divider\"></div>\n\n                    <div class=\"ts-content is-vertically-padded\">\n                        <!-- Channel Username -->\n                        <div class=\"ts-control is-wide\">\n                            <div class=\"label\">Channel Username</div>\n                            <div class=\"content\">\n                                <div class=\"ts-input is-start-labeled\">\n                                    <div class=\"label\">{{ .Config.Domain }}</div>\n                                    <input type=\"text\" name=\"username\" autofocus required />\n                                </div>\n                                <div class=\"ts-text is-description has-top-spaced-small\">Use commas to separate multiple channel names, e.g. \"channel1, channel2, channel3\".</div>\n                            </div>\n                        </div>\n                        <!-- / Channel Username -->\n\n                        <!-- Resolution -->\n                        <div class=\"ts-control is-wide has-top-spaced-large\">\n                            <div class=\"label\">Resolution</div>\n                            <div class=\"content\">\n                                <div class=\"ts-select\">\n                                    <select name=\"resolution\">\n                                        <option value=\"2160\" {{ if eq .Config.Resolution 2160 }}selected{{ end }}>4K</option>\n                                        <option value=\"1440\" {{ if eq .Config.Resolution 1440 }}selected{{ end }}>2K</option>\n                                        <option value=\"1080\" {{ if eq .Config.Resolution 1080 }}selected{{ end }}>1080p</option>\n                                        <option value=\"720\" {{ if eq .Config.Resolution 720 }}selected{{ end }}>720p</option>\n                                        <option value=\"540\" {{ if eq .Config.Resolution 540 }}selected{{ end }}>540p</option>\n                                        <option value=\"480\" {{ if eq .Config.Resolution 480 }}selected{{ end }}>480p</option>\n                                        <option value=\"240\" {{ if eq .Config.Resolution 240 }}selected{{ end }}>240p</option>\n                                    </select>\n                                </div>\n                                <div class=\"ts-text is-description has-top-spaced-small\">The lower resolution will be used if the selected resolution is not available.</div>\n                            </div>\n                        </div>\n                        <!-- / Resolution -->\n\n                        <!-- Framerate -->\n                        <div class=\"ts-control is-wide has-top-spaced-large\">\n                            <div class=\"label\">Framerate</div>\n                            <div class=\"content is-padded\">\n                                <div class=\"ts-wrap is-compact is-vertical\">\n                                    <label class=\"ts-radio\">\n                                        <input type=\"radio\" name=\"framerate\" value=\"60\" {{ if eq .Config.Framerate 60 }}checked{{ end }} />\n                                        60 FPS (or lower)\n                                    </label>\n                                    <label class=\"ts-radio\">\n                                        <input type=\"radio\" name=\"framerate\" value=\"30\" {{ if eq .Config.Framerate 30 }}checked{{ end }} />\n                                        30 FPS\n                                    </label>\n                                </div>\n                            </div>\n                        </div>\n                        <!-- / Framerate -->\n\n                        <!-- Filename Pattern -->\n                        <div class=\"ts-control is-wide has-top-spaced-large\">\n                            <div class=\"label\">Filename Pattern</div>\n                            <div class=\"content\">\n                                <div class=\"ts-input\">\n                                    <input type=\"text\" name=\"pattern\" value=\"{{ .Config.Pattern }}\" />\n                                </div>\n                                <div class=\"ts-text is-description has-top-spaced-small\">\n                                    See the <a class=\"ts-text is-external-link is-link\" href=\"https://github.com/teacat/chaturbate-dvr\" target=\"_blank\">README</a> for details.\n                                </div>\n                            </div>\n                        </div>\n                        <!-- / Filename Pattern -->\n\n                        <div class=\"ts-divider has-vertically-spaced-large\"></div>\n\n                        <!-- Splitting Options -->\n                        <div class=\"ts-control is-wide has-top-spaced\">\n                            <div class=\"label\">Splitting Options</div>\n                            <div class=\"content\">\n                                <div class=\"ts-content is-padded is-secondary\">\n                                    <div class=\"ts-grid is-relaxed is-2-columns\">\n                                        <div class=\"column\">\n                                            <div class=\"ts-text is-bold\">Max Filesize</div>\n                                            <div class=\"ts-input is-end-labeled has-top-spaced-small\">\n                                                <input type=\"number\" name=\"max_filesize\" value=\"{{ .Config.MaxFilesize }}\" />\n                                                <span class=\"label\">MB</span>\n                                            </div>\n                                        </div>\n                                        <div class=\"column\">\n                                            <div class=\"ts-text is-bold\">Max Duration</div>\n                                            <div class=\"ts-input is-end-labeled has-top-spaced-small\">\n                                                <input type=\"number\" name=\"max_duration\" value=\"{{ .Config.MaxDuration }}\" />\n                                                <span class=\"label\">Min(s)</span>\n                                            </div>\n                                        </div>\n                                    </div>\n                                    <div class=\"ts-text is-description has-top-spaced\">Splitting will be disabled if both options are 0.</div>\n                                </div>\n                            </div>\n                        </div>\n                        <!-- / Splitting Options -->\n                    </div>\n\n                    <div class=\"ts-divider\"></div>\n\n                    <div class=\"ts-content is-secondary is-horizontally-padded\">\n                        <div class=\"ts-wrap is-end-aligned\">\n                            <button type=\"reset\" class=\"ts-button is-outlined is-secondary\" data-dialog=\"create-dialog\">Cancel</button>\n                            <button type=\"submit\" class=\"ts-button is-primary\">Add Channel</button>\n                        </div>\n                    </div>\n                </form>\n            </div>\n        </dialog>\n        <!-- / Create Dialog -->\n\n        <script>\n            // updateRecordingCount counts recording channels\n            function updateRecordingCount() {\n                // Count badges that contain \"RECORDING\" text (not secondary or negative)\n                let count = 0;\n                document.querySelectorAll('.ts-badge').forEach(badge => {\n                    if (badge.textContent.trim() === 'RECORDING') {\n                        count++;\n                    }\n                });\n\n                const counter_el = document.getElementById('recording-counter');\n                 if (count > 0) {\n                    counter_el.textContent = `${count} ${count === 1 ? 'CHANNEL IS' : 'CHANNELS ARE'} RECORDING`;\n                } else {\n                    counter_el.textContent = `NO RECORDING`;\n                }\n            }\n\n            // before content was swapped by HTMX\n            document.body.addEventListener(\"htmx:sseBeforeMessage\", function (e) {\n                // ignore anything with `-log` content swap if \"auto-update\" was unchecked\n                let sswe_id = e.detail.elt.getAttribute('sse-swap')\n                if (sswe_id && sswe_id.endsWith(\"-log\") ) {\n                    if (!e.detail.elt.closest(\".ts-box\").querySelector(\"[type=checkbox]\").checked) {\n                        e.preventDefault()\n                        return\n                    }\n                }\n                // else scroll the textarea to bottom with async trick\n                setTimeout(() => {\n                    let textarea = e.detail.elt.closest(\".ts-box\").querySelector(\"textarea\")\n                    textarea.scrollTop = textarea.scrollHeight\n                }, 0)\n            })\n\n            // after content was swapped by HTMX (for status updates)\n            document.body.addEventListener(\"htmx:sseMessage\", function (e) {\n                // only update recording count if the content swap is for channel info\n                let sswe_id = e.detail.elt.getAttribute('sse-swap')\n                if (sswe_id && sswe_id.endsWith(\"-info\") ) {\n                    updateRecordingCount();\n                }\n            })\n\n            // Initial count on page load\n            document.addEventListener(\"DOMContentLoaded\", function() {\n                updateRecordingCount();\n            });\n\n            document.body.querySelectorAll(\"textarea\").forEach((textarea) => {\n                textarea.scrollTop = textarea.scrollHeight\n            })\n        </script>\n    </body>\n</html>\n"
  },
  {
    "path": "router/view/templates/site.webmanifest",
    "content": "{\"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\"}"
  },
  {
    "path": "router/view/view.go",
    "content": "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.FS\n\n// InfoTpl is a template for rendering channel information.\nvar InfoTpl *template.Template\n\nfunc init() {\n\tvar err error\n\n\tInfoTpl, err = template.New(\"update\").ParseFS(FS, \"templates/channel_info.html\")\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to parse template: %v\", err)\n\t}\n}\n\n// StaticFS initializes the static file system for serving frontend files.\nfunc StaticFS() (http.FileSystem, error) {\n\tfrontendFS, err := fs.Sub(FS, \"templates\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to initialize static files: %w\", err)\n\t}\n\treturn http.FS(frontendFS), nil\n}\n"
  },
  {
    "path": "server/config.go",
    "content": "package server\n\nimport \"github.com/teacat/chaturbate-dvr/entity\"\n\nvar Config *entity.Config\n"
  },
  {
    "path": "server/manager.go",
    "content": "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 interface {\n\tCreateChannel(conf *entity.ChannelConfig, shouldSave bool) error\n\tStopChannel(username string) error\n\tPauseChannel(username string) error\n\tResumeChannel(username string) error\n\tChannelInfo() []*entity.ChannelInfo\n\tPublish(name string, ch *entity.ChannelInfo)\n\tSubscriber(w http.ResponseWriter, r *http.Request)\n\tLoadConfig() error\n\tSaveConfig() error\n}\n"
  }
]