Package: feed-archiver
The$feed-archivercommand aims to archive RSS/Atom feeds as fully as possible in
such a way that the archive can serve (at least) 2 use cases:Mirror of Feed Enclosures and AssetsA mirror of the archived feeds that can be in turn served onto onto feed
clients/subscribers (such as podcatchers).  For example, you can subscribe to the
archived feed from your podcatcher app on your phone with auto-download and
auto-delete of podcast episodes while archiving those same episodes on your HTPC
server with large enough storage to keep all episodes.  The archived version of the
feed will also reflect the earliest form of feed XML, item XML, enclosures, and
assets that the archive downloaded and as such can be used to reflect the original
version to clients even as the remote feed changes over time.Ingest Feed Enclosures Into Media LibrariesAn alternate hierarchy of feed item enclosures better suited for ingestion into
other media software, such as media library servers.  For example, your podcast
episodes can also be made available in yourJellyfin/Emby/Plex library.Table of ContentsDetailed DescriptionMirror of Feed Enclosures and AssetsIngest Feed Enclosures Into Media LibrariesInstallationLocal/Native InstallationDocker Container Image InstallationUsagePluginsDefault Template PluginSonarr TV Series PluginContributingDetailed DescriptionMirror of Feed Enclosures and AssetsTo serve use case #1,feed-archiverdownloads enclosures and external assets
(e.g. feed and item logos specified as URLs in the feed XMLs) to the archive’s local
filesystem, adjusts the URLs of the downloaded items in the feed XML, and saves the feed
XML into the archive as well.  This makes the local archive filesystem suitable for
serving to feed clients/subscribers using a simple static site server such asnginx.All URLs are transformed into file-system paths that are as readable as possible while
avoiding special characters that may cause issues with common file-systems.
Specifically, special characters are%xxescaped usingPython’s
urllib.parse.quotefunction.  Note that this will double-escape any%xxescapes in the remote URL:.../foo?bar=qux%2Fbaz#corge->.../foo%3Fbar=qux%252Fbaz#corgeThen the URL is converted to a corresponding filesystem path:https://foo-username:secret@grault.example.com/feeds/garply.rss->./https/foo-username%3Asecret@grault.example.com/feeds/garply.rssAssuming the archived feeds are all hosted via HTTPS/TLS from annginx server_nameoffeeds.example.com, then subscribing to the archived feed in a syndication client,
such as a pod-catcher app can be done by transforming the URL like so:https://foo-username:secret@grault.example.com/feeds/garply.rss->https://feeds.example.com/https/foo-username%3Asecret@grault.example.com/feeds/garply.rssIOW, it’s as close as possible to simply prepending your archives host name to the feed
URL.As feeds change over time,feed-archiverpreserves the earliest form of feed content
as much as possible.  If a feed item is changed in a subsequent retrieval of the feed,
the remote item XML is preserved instead of updating to the newer XML.  More
specifically, items will be ignored on subsequent retrievals of the same feed if they
have the sameguid/idas items that have previously been archived for that feed.Ingest Feed Enclosures Into Media LibrariesTo serve use case #2,feed-archiverlinks the downloaded feed item enclosures into
an alternate hierarchy based on feed item metadata that better reflects the
show-with-episodes nature of most feeds, such as podcasts, with media enclosures.  What
feed item metadata is used and how it’s used to assemble the media library path
enclosures are linked into is configurable on a per-feed basis.  This can be used, for
example, simply to make your podcasts accessible from your media library software.  In a
more complex example, it can be used to link episodes from a podcast about a TV series
asexternal alternative audio tracksnext to the corresponding TV episode video file.
Multiple linking paths can be configured such that feed item enclosures can be ingested
in multiple locations in media libraries.Because syndication feeds may have a number of different ways to correspond to library
media, this functionality needs to be highly configurable and in order to be highly
configurable it is more complex to customize to a specific goal.  As such, using this
feature requires usingan enclosure plugin, or the skill level of a junior developer,
or someone who is comfortable reading and interpreting technical documentation, or
re-using example configurations known to work by others.InstallationLocal/Native InstallationInstall using any tool for installing standard Python 3 distributions such aspip:$ pip3 install --user feed-archiverOptional shell tab completion is available viaargcomplete.Docker Container Image InstallationThe recommended way to use the Docker container image is viaDocker Compose.  Seethe example ./docker-compose.yml filefor an example configuration.  Once you have
your configuration, you can create and run the container:$ docker compose upAlternatively, you make use the image directly.  Pullthe Docker image:$ docker pull "registry.gitlab.com/rpatterson/feed-archiver"And then use the image to create and run a container:$ docker run --rm -it "registry.gitlab.com/rpatterson/feed-archiver" ...Images variant tags are published for the Python version, branch, and major/minor
versions so that users can control when they get new images over time,
e.g.registry.gitlab.com/rpatterson/feed-archiver:py310-main.  The canonical Python
version is 3.10 which is the version used in tags withoutpy###,
e.g.registry.gitlab.com/rpatterson/feed-archiver:main.  Pre-releases are fromdevelopand final releases are frommainwhich is also the default for tags
without a branch, e.g.registry.gitlab.com/rpatterson/feed-archiver:py310. The
major/minor version tags are only applied to the final release images and without the
correspondingmainbranch tag,
e.g.registry.gitlab.com/rpatterson/feed-archiver:py310-v0.8.Multi-platform Docker images are published containing images for the following
platforms or architectures in the Python 3.10py310variant:linux/amd64linux/arm64linux/arm/v7UsageCreate a./.feed-archiver.ymlYAML file in a directory to serve as the root
directory for all feeds to be archived.  The YAML file must have a top-leveldefaultskey whose value is an object defining default or global options.  In
particular, thebase-urlkey in that section whose value must be a string which
defines the external base URL at which the archive is served to clients and is used to
assemble absolute URLs where relative URLs can’t be used.  The file must also have a
top-levelfeedskey whose value is an array or list of objects defining the remote
feeds to archive in this directory.  Each feed object must contain aremote-urlkey
whose value is a string that contains the URL of an individual feed to archive.  In the
simplest form, this can just be a file like so:defaults:
  base-url: "https://feeds.example.com"
feeds:
  - title: "Garply Podcast Title"
    remote-url: "\
    https://foo-username:secret@grault.example.com\
    /feeds/garply.rss?bar=qux%2Fbaz#corge"
...Then run the$feed-archivercommand in that directory to update the archive from
the current version of the feeds and write an HTML index with links to the archived
feeds:$ cd "/var/www/html/feeds/"
$ feed-archiver
INFO:Retrieving feed URL: https://foo-username:secret@grault.example.com/feeds/garply.rss
...
INFO:Writing HTML index: /var/www/html/feeds/index.htmlSee also the command-line help for details on options and arguments:$ feed-archiver --help
usage: feed-archiver [-h] [--log-level {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}]
                     [--archive-dir [ARCHIVE_DIR]]
                     {update,relink} ...

Archive RSS/Atom syndication feeds and their enclosures and assets.

positional arguments:
  {update,relink}       sub-command
    update              Request the URL of each feed in the archive and update contents accordingly.
    relink              Re-link enclosures to the correct locations for the current configuration.

options:
  -h, --help            show this help message and exit
  --log-level {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}
                        Select logging verbosity. (default: INFO)
  --archive-dir [ARCHIVE_DIR], -a [ARCHIVE_DIR]
                        the archive root directory into which all feeds, their enclosures and assets
                        will be downloaded (default: .)If using the Docker container image, the container can be run from the command-line as
well:$ docker compose run "feed-archiver" feed-archiver --help
usage: feed-archiver [-h]To link feed item enclosures into analternate hierarchy, such as in a media library,
add aenclosureskey to the feed configuration whose value is an list/array of
objects each defining one alternative path to link to the feed item enclosure.  Anyenclosuresdefined in the top-leveldefaultskey will be used for all feeds.
Configuration to be shared across multipleenclosuresconfigurations may be placed
in the correspondingdefaults/plugins/enclosures/{plugin_name}object.  The actual linking of enclosures is delegated toplugins.When updating the archive from the remote feed URLs using the$feed-archiverupdatesub-command, the enclosures of new items are linked as configured.  If theenclosuresconfiguration changes or any of the used plugins refer to external
resources that may change, such as the with thesonarrplugin whenSonarrhas
upgraded or renamed the corresponding video files, use the$feed-archiverrelinkcommand to update all existing links.PluginsHow feed item enclosures are linked into a media library is delegated to plugins or
add-ons.  Specifically, thepluginkey in aenclosuresconfiguration must be a
string which is the name ofa Python entry pointregistered in thefeedarchiver.enclosuresgroup.  The entry point object reference must point to afeedarchiver.enclosures.EnclosurePluginsubclass which accepts the following arguments
when instantiated:parent=dictThefeedarchiver.archive.Archiveif the plugin is configured indefaultsfor
all feeds or thefeedarchiver.feed.ArchiveFeedif defined for a specific feed.config=dictThe Python dictionary object from the de-serialized archive configuration YAML for
this specific enclosure configuration.and whose instances must be callable and accept the following arguments when called:archive_feed=feedarchiver.feed.ArchiveFeedThe objectfeedarchiveruses internally to represent an individual feed in the
archive.feed_elem=xml.etree.ElementTree.Element,item_elem=xml.etree.ElementTree.ElementThePython XML element objectrepresenting the whole feed, for RSS this is the<channel>child element while for Atom this is the root<feed>element, and
the a similar object representing the specific feed item.feed_parsed=feedparser.util.FeedParserDict,item_parsed=feedparser.util.FeedParserDictThefeedparserobject representing the whole feed and the specific feed item.url_result=lxml.etree._ElementUnicodeResultThelmxl special string objectthat contains the URL of the specific enclosure.
Can be used to access the specific enclosure element.enclosure_path=pathlib.PathThe path to the enclosure in the archive as aPython pathlib.Pathobject with the
best guess at the most correct file basename, including the suffix or extension, for
the given enclosure.  This suffix takes into account the suffix from the enclosure
URL, theContent-Typeheader of the response to the enclosure URL request, and
finally the value of anytypeattribute of the enclosure element XML.match=re.MatchThePython regular expression match objectif thematch-patternmatched the
string expanded from thePython format stringin thematch-stringkey.
Particularly useful to designateregular expression groupsin thematch-patternand then use the parts ofmatch-stringthat matched those
groups in the formattemplate.  If thematch-patterndoesn’t match then the
enclosure will not be linked. If there aresymbolic group names,
e.g.(?P<foo_group_name>.*)in the pattern, then they are also available by name
in the format string, e.g{foo_group_name.lower()}.  If nomatch-stringis
provided a default is used combining the feed title, item title, and enclosure
basename with extension:{utils.quote_sep(feed_parsed.feed.title).strip()}/{utils.quote_sep(item_parsed.title).strip()}{enclosure_path.suffix}If the plugin returns a value, it must be a list of strings and will be used as the
target paths at which to link the enclosure.  Relative paths are resolved against the
archive root.  These paths are not escaped, so if escaping is needed it must be a part
of the plugin configuration. If no plugins link a given enclosure, then any plugins
whosefallbackkey istruewill be applied. Here’s an exampleenclosuresdefinition:defaults:
  base-url: "https://feeds.example.com"
  plugins:
    enclosures:
      sonarr:
        url: "http://localhost:8989"
        api-key: "????????????????????????????????"
  enclosures:
    # Link all feed item enclosures into the media library under the podcasts
    # directory.  Link items into an album directory named by series title if
    # matching.
    - template: "\
      /media/Library/Music/Podcasts\
      /{utils.quote_sep(feed_parsed.feed.title).strip()}\
      /{series_title}\
      /{utils.quote_sep(item_parsed.title).strip()}{enclosure_path.suffix}"
      match-string: "{utils.quote_sep(item_parsed.title).strip()}"
      match-pattern: "\
      (?P<item_title>.+) \\((?P<series_title>.+) \
      (?P<season_number>[0-9])(?P<episode_numbers>[0-9]+[0-9Ee& -]*)\\)"
    # Otherwise link into "self-titled" album directories of the same name as the
    # feed.
    - template: "\
      /media/Library/Music/Podcasts\
      /{utils.quote_sep(feed_parsed.feed.title).strip()}\
      /{utils.quote_sep(feed_parsed.feed.title).strip()}\
      /{utils.quote_sep(item_parsed.title).strip()}{enclosure_path.suffix}"
      fallback: true
feeds:
  - remote-url: "\
    https://foo-username:secret@grault.example.com\
    /feeds/garply.rss?bar=qux%2Fbaz#corge"
    enclosures:
      # This particular feed is a podcast about a TV series/show.  Link enclosures
      # from feed items about an individual episode next to the episode video file as
      # an external audio track using a non-default plugin.
      - plugin: "sonarr"
        match-string: "{utils.quote_sep(item_parsed.title).strip()}"
        match-pattern: "\
        (?P<item_title>.+) \\((?P<series_title>.+) \
        (?P<season_number>[0-9])(?P<episode_numbers>[0-9]+[0-9Ee& -]*)\\)"
        stem-append: "-garply"
...Default Template PluginIf nopluginkey is specified, thetemplateplugin is used.  The link
path config may include thetemplatekey containing aPython format stringwhich
will be expanded to determine where the feed item enclosure should be linked to.  The
defaulttemplateis:./Feeds/{utils.quote_sep(feed_parsed.feed.title).strip()}/{utils.quote_sep(item_parsed.title).strip()}{enclosure_path.suffix}The format strings may reference any ofthe arguments passed into enclosure plugins.Sonarr TV Series PluginThesonarrplugin uses values from the enclosure configuration and/or thematchgroups to lookup a TV series/show managed bySonarr, then lookup an episode video
file that corresponds to the feed item enclosure, and link the enclosure next to that
video file.  Theenclosuresconfiguration ormatchgroups must contain:urlandapi-keyused toconnect to the Sonarr APIseries_idorseries_titleused tolook up the TV show/series, note that
usingseries_idsaves on Sonarr API request per updateseason_numberused tolookup the episode fileepisode_numbersused tolookup the episode file, plural to support
multi-episode filesThey may also include:stem-appendcontaining a string to append to the episode file stem before the
enclosure suffix/extensionContributingNOTE:This project is hosted on GitLab.  There’sa mirror on GitHubbut please use
GitLab for reporting issues, submitting PRs/MRs and any other development or maintenance
activity.Seethe ./CONTRIBUTING.rst filefor more details on how to get started with
development.