Skip to content
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,29 @@ volumes:
- To download both live streams and VODs, add the same channel twice with different modes
- Currently supported for Twitch only

## Post-Download Script Hook

You can configure a script to run automatically after each successful download. The script is set per-site using the `post_script` field:

```yaml
- site: twitch.tv
post_script: /scripts/transcode.sh
channels:
- name: kaicenat
quality: best
```

The script receives the file path as its first argument, and additional context via environment variables:

| Variable | Description | Example |
|---|---|---|
| `STREAMDL_FILE` | Path to the downloaded file | `/data/complete/user_2026-04-14.mp4` |
| `STREAMDL_USER` | Channel/user name | `kaicenat` |
| `STREAMDL_SITE` | Site domain | `twitch.tv` |
| `STREAMDL_TYPE` | Download type | `live` or `vod` |

The script runs asynchronously and will not block other downloads. If the script fails, an error is logged but StreamDL continues operating normally.

## Environment Variables

StreamDL supports configuration through environment variables for certain system-level settings.
Expand Down
5 changes: 3 additions & 2 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ package main

// Config represents a streaming site and its list of channels to monitor.
type Config struct {
Site string `yaml:"site"`
Streamers []Streamer `yaml:"channels"`
Site string `yaml:"site"`
Streamers []Streamer `yaml:"channels"`
PostScript string `yaml:"post_script"`
}

// Streamer represents a single channel to monitor, with quality and VOD settings.
Expand Down
30 changes: 30 additions & 0 deletions config_reader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,36 @@ func TestParseConfig_VODFields(t *testing.T) {
}
}

func TestParseConfig_PostScript(t *testing.T) {
yamlData := []byte(`
- site: twitch.tv
post_script: /scripts/transcode.sh
channels:
- name: testuser
quality: best
- site: youtube.com
channels:
- name: otheruser
quality: best
`)
config, err := parseConfig(yamlData)
if err != nil {
t.Fatalf("Failed to parse config: %v", err)
}

if len(config) != 2 {
t.Fatalf("Expected 2 site configs, got %d", len(config))
}

if config[0].PostScript != "/scripts/transcode.sh" {
t.Errorf("Expected PostScript '/scripts/transcode.sh', got %q", config[0].PostScript)
}

if config[1].PostScript != "" {
t.Errorf("Expected empty PostScript for second site, got %q", config[1].PostScript)
}
}

func TestParseConfig_MalformedYAML(t *testing.T) {
dir := t.TempDir()
cfg := filepath.Join(dir, "bad.yml")
Expand Down
22 changes: 20 additions & 2 deletions download_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func createDirWithUmask(path string) error {

// downloadStream records a live stream via FFmpeg, retrying on transient failures.
// It removes the user from the live list on exit and moves the finished file to moveLoc.
func downloadStream(user string, url string, outLoc string, moveLoc string, subfolder bool, control <-chan bool, response chan<- bool) {
func downloadStream(user string, url string, outLoc string, moveLoc string, subfolder bool, site string, postScript string, control <-chan bool, response chan<- bool) {
naturalFinish := make(chan error, 1)
sigint := make(chan bool)
t := time.Now().Format("2006-01-02_15-04-05")
Expand Down Expand Up @@ -254,6 +254,15 @@ func downloadStream(user string, url string, outLoc string, moveLoc string, subf
log.Errorf("Failed to move file: %v", err)
} else {
log.Debugf("Moved file to %v", newPath)
if postScript != "" {
postScriptWg.Add(1)
go func() {
defer postScriptWg.Done()
if err := runPostScript(postScript, newPath, user, site, "live"); err != nil {
log.Errorf("post_script failed for %s: %v", user, err)
}
}()
}
}
return
}
Expand Down Expand Up @@ -444,7 +453,7 @@ func sanitizeFilename(name string) string {

// downloadVOD downloads a single VOD and updates its status in the database.
// The url parameter is a resolved stream URL (from GetStream via Streamlink/yt-dlp).
func downloadVOD(user string, vod VodResult, url string, outLoc string, moveLoc string, subfolder bool, vodDB *VodDB, control <-chan bool) {
func downloadVOD(user string, vod VodResult, url string, outLoc string, moveLoc string, subfolder bool, site string, postScript string, vodDB *VodDB, control <-chan bool) {
sanitizedTitle := sanitizeFilename(vod.Title)
fileBase := user + "_vod_" + vod.ID
if sanitizedTitle != "" {
Expand Down Expand Up @@ -581,6 +590,15 @@ func downloadVOD(user string, vod VodResult, url string, outLoc string, moveLoc
log.Errorf("Failed to mark VOD %s as completed: %v", vod.ID, err)
}
}
if postScript != "" {
postScriptWg.Add(1)
go func() {
defer postScriptWg.Done()
if err := runPostScript(postScript, newPath, user, site, "vod"); err != nil {
log.Errorf("post_script failed for VOD %s: %v", vod.ID, err)
}
}()
}
}

return
Expand Down
57 changes: 57 additions & 0 deletions post_script.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package main

import (
"context"
"fmt"
"os"
"os/exec"
"syscall"
"time"

log "github.com/sirupsen/logrus"
)

// runPostScript executes a user-defined script after a successful download.
// The file path is passed as the first argument, and context is provided via
// STREAMDL_FILE, STREAMDL_USER, STREAMDL_SITE, and STREAMDL_TYPE env vars.
// Returns nil immediately if scriptPath is empty (no hook configured).
func runPostScript(scriptPath, filePath, user, site, dlType string) error {
if scriptPath == "" {
return nil
}

info, err := os.Stat(scriptPath)
if err != nil {
return fmt.Errorf("post_script not found: %w", err)
}
if info.Mode().Perm()&0111 == 0 {
return fmt.Errorf("post_script %s is not executable", scriptPath)
}

log.Infof("Running post_script %s for %s (%s)", scriptPath, user, filePath)

timeout := time.Duration(parseIntEnvOrDefault("STREAMDL_POST_SCRIPT_TIMEOUT", 1800)) * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

cmd := exec.CommandContext(ctx, scriptPath, filePath)
cmd.Env = append(os.Environ(),
"STREAMDL_FILE="+filePath,
"STREAMDL_USER="+user,
"STREAMDL_SITE="+site,
"STREAMDL_TYPE="+dlType,
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}

if err := cmd.Run(); err != nil {
return fmt.Errorf("post_script %s failed: %w", scriptPath, err)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

log.Infof("post_script %s completed for %s", scriptPath, user)
return nil
}
103 changes: 103 additions & 0 deletions post_script_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package main

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestRunPostScript_Success(t *testing.T) {
dir := t.TempDir()
marker := filepath.Join(dir, "marker.txt")

// Create a script that writes env vars to a marker file
script := filepath.Join(dir, "hook.sh")
scriptContent := "#!/bin/sh\necho \"$STREAMDL_FILE|$STREAMDL_USER|$STREAMDL_SITE|$STREAMDL_TYPE\" > " + marker + "\n"
if err := os.WriteFile(script, []byte(scriptContent), 0755); err != nil {
t.Fatalf("write script: %v", err)
}

err := runPostScript(script, "/data/complete/user_2026.mp4", "testuser", "twitch.tv", "live")
if err != nil {
t.Fatalf("runPostScript error: %v", err)
}

got, err := os.ReadFile(marker)
if err != nil {
t.Fatalf("read marker: %v", err)
}

expected := "/data/complete/user_2026.mp4|testuser|twitch.tv|live\n"
if string(got) != expected {
t.Errorf("marker content = %q, want %q", string(got), expected)
}
}

func TestRunPostScript_EmptyPath_Noop(t *testing.T) {
err := runPostScript("", "/data/file.mp4", "user", "twitch.tv", "live")
if err != nil {
t.Fatalf("expected nil for empty script path, got: %v", err)
}
}

func TestRunPostScript_MissingScript(t *testing.T) {
err := runPostScript("/nonexistent/script.sh", "/data/file.mp4", "user", "twitch.tv", "live")
if err == nil {
t.Fatal("expected error for missing script")
}
}

func TestRunPostScript_ScriptFails(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "fail.sh")
if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 1\n"), 0755); err != nil {
t.Fatalf("write script: %v", err)
}

err := runPostScript(script, "/data/file.mp4", "user", "twitch.tv", "vod")
if err == nil {
t.Fatal("expected error for failing script")
}
}

func TestRunPostScript_FilePathAsFirstArg(t *testing.T) {
dir := t.TempDir()
marker := filepath.Join(dir, "arg.txt")

script := filepath.Join(dir, "checkarg.sh")
scriptContent := "#!/bin/sh\necho \"$1\" > " + marker + "\n"
if err := os.WriteFile(script, []byte(scriptContent), 0755); err != nil {
t.Fatalf("write script: %v", err)
}

err := runPostScript(script, "/data/complete/test.mp4", "user", "twitch.tv", "live")
if err != nil {
t.Fatalf("runPostScript error: %v", err)
}

got, err := os.ReadFile(marker)
if err != nil {
t.Fatalf("read marker: %v", err)
}

if string(got) != "/data/complete/test.mp4\n" {
t.Errorf("arg content = %q, want %q", string(got), "/data/complete/test.mp4\n")
}
}

func TestRunPostScript_NotExecutable(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "noexec.sh")
if err := os.WriteFile(script, []byte("#!/bin/sh\necho hi\n"), 0644); err != nil {
t.Fatalf("write script: %v", err)
}

err := runPostScript(script, "/data/file.mp4", "user", "twitch.tv", "live")
if err == nil {
t.Fatal("expected error for non-executable script")
}
if !strings.Contains(err.Error(), "not executable") {
t.Errorf("expected 'not executable' in error, got: %v", err)
}
}
12 changes: 7 additions & 5 deletions streamdl.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ import (
)

var (
urls = make(map[string]string)
urlsMu sync.RWMutex
vodWg sync.WaitGroup
urls = make(map[string]string)
urlsMu sync.RWMutex
vodWg sync.WaitGroup
postScriptWg sync.WaitGroup
)
var c = make(chan os.Signal, 2)

Expand Down Expand Up @@ -166,7 +167,7 @@ func main() {
vodWg.Add(1)
go func() {
defer vodWg.Done()
downloadVOD(streamer.User, vod, resolvedURL, *vodOutLoc, *vodMoveLoc, *subfolder, vodDB, control)
downloadVOD(streamer.User, vod, resolvedURL, *vodOutLoc, *vodMoveLoc, *subfolder, site.Site, site.PostScript, vodDB, control)
}()
}
} else {
Expand Down Expand Up @@ -194,7 +195,7 @@ func main() {
urls[streamer.User] = url
urlsMu.Unlock()
log.Debugf("Discovered live stream for user=%s", streamer.User)
go downloadStream(streamer.User, url, *outLoc, *moveLoc, *subfolder, control, response)
go downloadStream(streamer.User, url, *outLoc, *moveLoc, *subfolder, site.Site, site.PostScript, control, response)
break
}

Expand Down Expand Up @@ -238,6 +239,7 @@ func main() {
<-response
}
vodWg.Wait()
postScriptWg.Wait()
time.Sleep(time.Second * 3)
return
case t := <-ticker.C:
Expand Down
2 changes: 2 additions & 0 deletions tests/integration/docker-compose.integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ services:
- ./output/complete:/app/out
- ./config:/app/config
- ./data:/app/data
- ./hooks:/app/hooks
- ./output/hook-markers:/app/hook-markers
depends_on:
server:
condition: service_healthy
Loading
Loading