summaryrefslogtreecommitdiff
path: root/podcast.py
blob: 9ee23d3e63c77dc2c0913370c1536b1eca4c5ca4 (plain)
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