summaryrefslogtreecommitdiff
path: root/podcast.py
diff options
context:
space:
mode:
authorMatthew Fennell <matthew@fennell.dev>2026-08-28 17:06:25 +0100
committerMatthew Fennell <matthew@fennell.dev>2026-08-29 01:06:29 +0100
commit7dd5c2186e2706a75ba719db88f5f1493184e7d6 (patch)
treeb6dbd5da8043b0f9c28d70b65bc3c89eaa9d8cd7 /podcast.py
parent4982ef0e700193cbe9a30fd032ed2e385a701159 (diff)
Add podcast handlermain
Diffstat (limited to 'podcast.py')
-rw-r--r--podcast.py118
1 files changed, 118 insertions, 0 deletions
diff --git a/podcast.py b/podcast.py
new file mode 100644
index 0000000..9ee23d3
--- /dev/null
+++ b/podcast.py
@@ -0,0 +1,118 @@
+# SPDX-FileCopyrightText: 2026 Matthew Fennell <matthew@fennell.dev>
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+from feed2exec.utils import slug
+
+from filelock import FileLock
+from pathlib import PosixPath
+from random import choice
+from shutil import which
+from string import ascii_lowercase
+from subprocess import PIPE, Popen
+import logging
+
+def output(*args, feed=None, item=None, session=None, **kwargs):
+ """The podcast plugin saves the audio file at the enclosure's url to the
+ directory specified by the mailbox and folder, followed by a folder named
+ after the feed's title. The intention is for the mailbox to be the root
+ under which podcasts are stored, the folder to be used for categories, and
+ the title to separate multiple podcasts of the same category - like so:
+
+ podcasts
+ ├── entertainment
+ │   ├── friday-night-comedy
+ │   │   └── friday-night-comedy-from-bbc-radio-4-yeft.opus
+ │   └── just-a-minute
+ │   ├── series-97-5-how-to-win-at-rock-paper-scissors-bmbg.opus
+ │   └── series-97-6-has-paul-joined-tiktok-txky.opus
+ └── news
+ └── bbc-news
+ └── 28-08-2026-22-01-gmt-hqmw.opus
+
+ In the process, it severely compresses the audio using ffmpeg to save
+ space. It also appends the filepath to an m3u playlist at the root of the
+ mailbox, using a lockfile to avoid contention when run in parallel.
+ Finally, it appends short random strings to the filenames, to prevent
+ overwriting podcasts that use identical across episodes.
+
+ Example::
+
+ [Friday Night Comedy]
+ url = https://podcasts.files.bbci.co.uk/p02pc9pj.rss
+ output = podcast
+ mailbox = /home/matthew/podcasts
+ folder = entertainment
+
+ The above will save compressed .opus files into
+ /home/matthew/podcasts/entertainment/friday-night-comedy.
+ """
+
+ if not feed.get("mailbox") or not feed.get("folder") or not feed.get("name"):
+ logging.error(f"mailbox, folder or name not present on {feed}")
+ return False
+
+ mailbox_dir = PosixPath(feed["mailbox"])
+ feed_dir = PosixPath(mailbox_dir, feed["folder"], slug(feed["name"]))
+
+ if not item.get("title"):
+ logging.error(f"name not present on {item}")
+ return False
+
+ unique_suffix = "".join(choice(ascii_lowercase) for i in range(4))
+ podcast_filename = slug(item["title"] + " " + unique_suffix)
+ podcast_filepath = PosixPath(feed_dir, podcast_filename).with_suffix(".opus")
+
+ if len(item.enclosures) != 1:
+ logging.error(f"!= 1 enclosure on {item.enclosures}")
+
+ enclosure = item.enclosures[0]
+
+ if not enclosure.get("url") and not enclosure.get("href"):
+ logging.error(f"url/href not present on {enclosure}")
+
+ url = enclosure.get("url", enclosure.href)
+
+ playlist_filepath = PosixPath(mailbox_dir, "playlist").with_suffix(".m3u")
+ playlist_lock = FileLock(playlist_filepath.with_suffix(".lock"))
+
+ if feed.get("catchup"):
+ return True
+
+ feed_dir.mkdir(parents=True, exist_ok=True)
+ logging.debug(f"Ensured feed dir {feed_dir} exists")
+
+ logging.debug(f"Fetching from {url}")
+
+ wget = Popen([
+ which("wget"),
+ "--quiet",
+ "--output-document", "-",
+ url,
+ ], stdout=PIPE)
+
+ ffmpeg = Popen([
+ which("ffmpeg"),
+ "-loglevel", "fatal",
+ "-i", "-",
+ "-c:a", "libopus",
+ "-ac", "1",
+ "-b:a", "12K",
+ "-vbr", "constrained",
+ "-y",
+ str(podcast_filepath),
+ ], stdin=wget.stdout)
+
+ wget.stdout.close()
+ ffmpeg.communicate()
+
+ logging.debug(f"Wrote to {podcast_filepath}")
+
+ with playlist_lock:
+ with open(playlist_filepath, "a") as playlist_file:
+ relative_path = str(podcast_filepath.relative_to(feed["mailbox"]))
+ playlist_file.write(relative_path + "\n")
+
+ logging.debug(f"Wrote to {playlist_filepath}")
+
+ return True