diff --git a/README.md b/README.md index 559c3f72..ecc8c290 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/config.go b/config.go index 915394d8..bf62a1fe 100644 --- a/config.go +++ b/config.go @@ -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. diff --git a/config_reader_test.go b/config_reader_test.go index 8a325d52..20c96767 100644 --- a/config_reader_test.go +++ b/config_reader_test.go @@ -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") diff --git a/download_stream.go b/download_stream.go index cfb6a0b8..f9a3a19a 100644 --- a/download_stream.go +++ b/download_stream.go @@ -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") @@ -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 } @@ -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 != "" { @@ -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 diff --git a/post_script.go b/post_script.go new file mode 100644 index 00000000..c7c1c90c --- /dev/null +++ b/post_script.go @@ -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) + } + + log.Infof("post_script %s completed for %s", scriptPath, user) + return nil +} diff --git a/post_script_test.go b/post_script_test.go new file mode 100644 index 00000000..01cf9327 --- /dev/null +++ b/post_script_test.go @@ -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) + } +} diff --git a/streamdl.go b/streamdl.go index 7474f785..2c7ec718 100644 --- a/streamdl.go +++ b/streamdl.go @@ -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) @@ -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 { @@ -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 } @@ -238,6 +239,7 @@ func main() { <-response } vodWg.Wait() + postScriptWg.Wait() time.Sleep(time.Second * 3) return case t := <-ticker.C: diff --git a/tests/integration/docker-compose.integration.yml b/tests/integration/docker-compose.integration.yml index e6a63e20..8d4ae272 100644 --- a/tests/integration/docker-compose.integration.yml +++ b/tests/integration/docker-compose.integration.yml @@ -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 diff --git a/tests/integration/run.sh b/tests/integration/run.sh index b4ae4209..b89a39b7 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -47,10 +47,12 @@ CANDIDATE_CHANNELS=( esl_csgo ) +HOOKS_DIR="$SCRIPT_DIR/hooks" + cleanup() { echo "--- Tearing down ---" $DC -f "$COMPOSE_FILE" down --volumes --remove-orphans 2>/dev/null || true - rm -rf "$OUTPUT_DIR" "$CONFIG_DIR" + rm -rf "$OUTPUT_DIR" "$CONFIG_DIR" "$HOOKS_DIR" } trap cleanup EXIT @@ -58,8 +60,15 @@ echo "=== StreamDL Integration Test ===" echo "" # Clean slate -rm -rf "$OUTPUT_DIR" "$CONFIG_DIR" -mkdir -p "$OUTPUT_DIR/incomplete" "$OUTPUT_DIR/complete" "$CONFIG_DIR" +rm -rf "$OUTPUT_DIR" "$CONFIG_DIR" "$HOOKS_DIR" +mkdir -p "$OUTPUT_DIR/incomplete" "$OUTPUT_DIR/complete" "$OUTPUT_DIR/hook-markers" "$CONFIG_DIR" "$HOOKS_DIR" + +# Create post-download hook script that writes a marker file with context +cat > "$HOOKS_DIR/post_hook.sh" <<'HOOKEOF' +#!/bin/sh +echo "${STREAMDL_TYPE}|${STREAMDL_USER}|${STREAMDL_SITE}|${STREAMDL_FILE}" > "/app/hook-markers/${STREAMDL_TYPE}_${STREAMDL_USER}.txt" +HOOKEOF +chmod +x "$HOOKS_DIR/post_hook.sh" # --- Phase 1: Start the server and find a live stream --- echo "--- Building and starting server ---" @@ -125,6 +134,7 @@ echo "--- Using channel: $LIVE_CHANNEL ---" # --- Phase 2: Generate config and start the client --- cat > "$CONFIG_DIR/config.yml" < "$CONFIG_DIR/config.yml" < +# quality: best +# 4. Wait for the streamer to end their stream (or start/stop one) +# 5. Check /tmp/streamdl_hook_log.txt for output +# +# The script logs all context it receives so you can verify everything works. + +LOGFILE="/tmp/streamdl_hook_log.txt" + +echo "========================================" >> "$LOGFILE" +echo "post_script fired at: $(date)" >> "$LOGFILE" +echo " File: $STREAMDL_FILE" >> "$LOGFILE" +echo " User: $STREAMDL_USER" >> "$LOGFILE" +echo " Site: $STREAMDL_SITE" >> "$LOGFILE" +echo " Type: $STREAMDL_TYPE" >> "$LOGFILE" +echo " \$1: $1" >> "$LOGFILE" +echo "========================================" >> "$LOGFILE"