# SPDX-FileCopyrightText: 2026 Matthew Fennell # # 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