Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ any device in podcast client.
- Feeds customizations (custom artwork, category, language, etc).
- OPML export.
- Supports episodes cleanup (keep last X episodes).
- Configurable hooks for custom integrations and workflows.
- One-click deployment for AWS.
- Runs on Windows, Mac OS, Linux, and Docker.
- Supports ARM.
Expand Down
14 changes: 14 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@ vimeo = [ # Multiple keys will be rotated.
# When set to true, podcasts indexers such as iTunes or Google Podcasts will not index this podcast
private_feed = true

# Optional post-episode download hooks
# Execute commands after each episode is downloaded
# Available environment variables: EPISODE_FILE, FEED_NAME, EPISODE_TITLE

# Webhook notification example
[[feeds.ID1.post_episode_download]]
command = ["curl", "-X", "POST", "-d", "New episode: $EPISODE_TITLE", "https://webhook.example.com/notify"]
timeout = 30

# Custom script example
[[feeds.ID1.post_episode_download]]
command = ["/path/to/your/process-episode.sh"]
timeout = 120

# Optional feed customizations
[feeds.ID1.custom]
title = "Level1News"
Expand Down
7 changes: 7 additions & 0 deletions pkg/feed/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ type Config struct {
Custom Custom `toml:"custom"`
// List of additional youtube-dl arguments passed at download time
YouTubeDLArgs []string `toml:"youtube_dl_args"`
// Post episode download hooks - executed after each episode is successfully downloaded
// Multiple hooks can be configured and will execute in sequence
// Example:
// [[feeds.ID1.post_episode_download]]
// command = ["echo", "Downloaded: $EPISODE_TITLE"]
// timeout = 10
PostEpisodeDownload []*ExecHook `toml:"post_episode_download"`
// Included in OPML file
OPML bool `toml:"opml"`
// Private feed (not indexed by podcast aggregators)
Expand Down
85 changes: 85 additions & 0 deletions pkg/feed/hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package feed

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

// ExecHook represents a single hook configuration that executes commands
// after specific lifecycle events (e.g., episode downloads).
//
// Example configuration:
//
// [[feeds.ID1.post_episode_download]]
// command = ["curl", "-X", "POST", "-d", "$EPISODE_TITLE", "webhook.example.com"]
// timeout = 30
//
// Environment variables available to hooks:
// - EPISODE_FILE: Path to downloaded file (e.g., "podcast-id/episode.mp3")
// - FEED_NAME: The feed identifier
// - EPISODE_TITLE: The episode title
type ExecHook struct {
// Command is the command and arguments to execute.
// For single commands, use shell parsing: ["echo hello"]
// For multiple args, pass directly: ["curl", "-X", "POST", "url"]
Command []string `toml:"command"`

// Timeout in seconds for command execution.
// If 0 or unset, defaults to 60 seconds.
Timeout int `toml:"timeout"`
}

// Invoke executes the hook command with the provided environment variables.
//
// The method handles nil hooks gracefully (returns nil) and validates that
// the command is not empty. Commands are executed with a timeout (default 60s)
// and inherit the parent process environment plus any additional variables.
//
// Single-element commands are executed via shell (/bin/sh -c), while
// multi-element commands are executed directly for better security.
//
// Returns an error if the command fails, times out, or returns a non-zero exit code.
// The error includes the combined stdout/stderr output for debugging.
func (h *ExecHook) Invoke(env []string) error {
if h == nil {
return nil
}
if len(h.Command) == 0 {
return fmt.Errorf("hook command is empty")
}

// Set up context with timeout (default 1 minute if not specified)
timeout := h.Timeout
if timeout == 0 {
timeout = 60 // default to 1 minute
}

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

// Create command with context
var cmd *exec.Cmd
if len(h.Command) == 1 {
// Single command, use shell to parse
cmd = exec.CommandContext(ctx, "/bin/sh", "-c", h.Command[0])
} else {
// Multiple arguments, use directly
cmd = exec.CommandContext(ctx, h.Command[0], h.Command[1:]...)
}

// Set up environment variables
cmd.Env = append(os.Environ(), env...)

// Execute the command
data, err := cmd.CombinedOutput()
output := string(data)

if err != nil {
return fmt.Errorf("hook execution failed: %v, output: %s", err, output)
}

return nil
}
109 changes: 109 additions & 0 deletions pkg/feed/hooks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package feed

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

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestExecuteHook_WriteEnvToFile(t *testing.T) {
// Create a temporary file
tempDir := t.TempDir()
tempFile := filepath.Join(tempDir, "env_output.txt")

hook := &ExecHook{
Command: []string{"sh", "-c", "printenv | grep '^TEST_VAR=' > " + tempFile},
Timeout: 5,
}

env := []string{
"TEST_VAR=test-value",
}

err := hook.Invoke(env)
require.NoError(t, err)

// Read the file and verify contents
content, err := os.ReadFile(tempFile)
require.NoError(t, err)

output := string(content)
assert.Contains(t, output, "TEST_VAR=test-value")
}

func TestExecuteHook_CornerCases(t *testing.T) {
tests := []struct {
name string
hook *ExecHook
env []string
expectError bool
errorMsg string
}{
{
name: "nil hook",
hook: nil,
env: []string{"TEST=value"},
expectError: false,
},
{
name: "empty command",
hook: &ExecHook{
Command: []string{},
},
env: []string{"TEST=value"},
expectError: true,
errorMsg: "hook command is empty",
},
{
name: "invalid command",
hook: &ExecHook{
Command: []string{"nonexistentcommand12345"},
},
env: []string{"TEST=value"},
expectError: true,
errorMsg: "hook execution failed",
},
{
name: "successful command",
hook: &ExecHook{
Command: []string{"echo", "test"},
},
env: []string{"TEST=value"},
expectError: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.hook.Invoke(tt.env)

if tt.expectError {
require.Error(t, err)
if tt.errorMsg != "" {
assert.Contains(t, err.Error(), tt.errorMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}

func TestExecuteHook_CurlWebhook(t *testing.T) {
hook := &ExecHook{
Command: []string{"curl -s -X POST -d \"$EPISODE_TITLE\" httpbin.org/post"},
Timeout: 10,
}

env := []string{
"EPISODE_TITLE=Test Episode for Webhook",
"FEED_NAME=test-podcast",
"EPISODE_FILE=test-podcast/episode001.mp3",
}

err := hook.Invoke(env)
assert.NoError(t, err, "Curl webhook should execute successfully")
}
17 changes: 17 additions & 0 deletions services/update/updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,23 @@ func (u *Manager) downloadEpisodes(ctx context.Context, feedConfig *feed.Config,
return err
}

// Execute post episode download hooks
if len(feedConfig.PostEpisodeDownload) > 0 {
env := []string{
"EPISODE_FILE=" + fmt.Sprintf("%s/%s", feedID, episodeName),
"FEED_NAME=" + feedID,
"EPISODE_TITLE=" + episode.Title,
}

for i, hook := range feedConfig.PostEpisodeDownload {
if err := hook.Invoke(env); err != nil {
logger.Errorf("failed to execute post episode download hook %d: %v", i+1, err)
} else {
logger.Infof("post episode download hook %d executed successfully", i+1)
}
}
}

// Update file status in database

logger.Infof("successfully downloaded file %q", episode.ID)
Expand Down
Loading