1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
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
|