Repository: mxpv/podsync

Issue:
Feature Request: Send webhook on feed update
I'd like to be able to trigger an HTTP request when a feed completes updating if there are new episodes available. Some use cases for this:

* Overcast has [an API to trigger a feed refresh](https://overcast.fm/podcasterinfo) (This is the one I want)
* [Podping](https://podcasting20.substack.com/p/podping) is a similar (public-only) update mechanism, although I'm not sure what apps if any use it yet
* Sending notifications about new episodes through a messaging app like Discord or Slack

I'm happy to work on this myself, if you'd accept a PR for it. I'm not sure what an appropriate config setting for this would look like, though. I have a couple of ideas:

1. A new `webhook` option:

```toml
[feeds]
  [feeds.ID1]
    ...
    webhook = { method = "POST", url = "https://mywebsite.biz/ping", body = "{\"url\": \"$FEED_URL\"}" }
```

2. A more generic `on_update` option that runs a command or script when the update finishes:

```toml
[feeds]
  [feeds.ID1]
    ...
    on_update = "curl -X POST --data \"{\\\"url\\\": \\\"$FEED_URL\\\"}\" https://mywebsite.biz/ping"
```

This would allow more complex integrations, for example, as suggested in another recent issue, uploading new episodes to Archive.org using [their API](https://archive.org/developers/tutorial-create-item.html). Realistically, though, it would probably require users to build their own docker container to do anything nontrivial (I don't think curl is included in the alpine docker image, for one).

For both of these I'm envisioning some kind of template variable system where feed properties are exposed and can be inserted into the strings.



Hints from issue comments:
@ejj: Do the maintainers have interest in accepting a PR for this?  If so I would seriously consider implementing it.@mxpv: >Do the maintainers have interest in accepting a PR for this?

Yes!

Solution patch:
diff --git a/README.md b/README.md
index 9ddf424..a8670fe 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/config.toml.example b/config.toml.example
index 539735f..375f248 100644
--- a/config.toml.example
+++ b/config.toml.example
@@ -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"
diff --git a/pkg/feed/config.go b/pkg/feed/config.go
index 379b1ea..8897a13 100644
--- a/pkg/feed/config.go
+++ b/pkg/feed/config.go
@@ -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)
diff --git a/pkg/feed/hooks.go b/pkg/feed/hooks.go
new file mode 100644
index 0000000..da9f157
--- /dev/null
+++ b/pkg/feed/hooks.go
@@ -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
+}
diff --git a/services/update/updater.go b/services/update/updater.go
index 755fa70..8cc9f72 100644
--- a/services/update/updater.go
+++ b/services/update/updater.go
@@ -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)



Test patch:
diff --git a/pkg/feed/hooks_test.go b/pkg/feed/hooks_test.go
new file mode 100644
index 0000000..412c485
--- /dev/null
+++ b/pkg/feed/hooks_test.go
@@ -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")
+}