Skip to main content

Archive the full contents of RSS/Atom syndication feeds including enclosures and assets.

Project description

PyPI latest release version
PyPI downloads per month
PyPI Python versions
Python code style
GitLab latest release
GitLab CI/CD pipeline status
GitLab coverage report
GitLab repo stars
GitHub release (latest SemVer)
GitHub Actions status
Codecov test coverage
GitHub repo stars
Docker Hub image version (latest semver)
Docker Hub image pulls count
Docker Hub stars
Docker Hub image size (latest semver)
KeyBase PGP key ID
GitHub followers count
LiberaPay donated per week
LiberaPay patrons count

The $ feed-archiver command aims to archive RSS/Atom feeds as fully as possible in such a way that the archive can serve (at least) 2 use cases:

  1. Mirror of Feed Enclosures and Assets

    A 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.

  2. Ingest Feed Enclosures Into Media Libraries

    An 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 your Jellyfin/Emby/Plex library.

Detailed Description

Mirror of Feed Enclosures and Assets

To serve use case #1, feed-archiver downloads 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 as nginx.

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 %xx escaped using Python’s urllib.parse.quote function. Note that this will double-escape any %xx escapes in the remote URL:

.../foo?bar=qux%2Fbaz#corge -> .../foo%3Fbar=qux%252Fbaz#corge

Then 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.rss

Assuming the archived feeds are all hosted via HTTPS/TLS from an nginx server_name of feeds.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.rss

IOW, it’s as close as possible to simply prepending your archives host name to the feed URL.

As feeds change over time, feed-archiver preserves 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 same guid/id as items that have previously been archived for that feed.

Ingest Feed Enclosures Into Media Libraries

To serve use case #2, feed-archiver links 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 as external alternative audio tracks next 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 using an 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.

Installation

Install and use either via a local, native installation or a Docker container image:

Local/Native Installation

Install using any tool for installing standard Python 3 distributions such as pip:

$ pip3 install --user feed-archiver

Optional shell tab completion is available via argcomplete.

Docker Container Image Installation

The recommended way to use the Docker container image is via Docker Compose. See the example ./docker-compose.yml file for an example configuration. Once you have your configuration, you can create and run the container:

$ docker compose up

Alternatively, you make use the image directly. Pull the Docker image:

$ docker pull "registry.gitlab.org/rpatterson/feed-archiver"

And then use the image to create and run a container:

$ docker run --rm -it "registry.gitlab.org/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.org/merpatterson/feed-archiver:py310-main. The canonical Python version is 3.10 which is the version used in tags without py###, e.g. registry.gitlab.org/merpatterson/feed-archiver:main. Pre-releases are from develop and final releases are from main which is also the default for tags without a branch, e.g. registry.gitlab.org/merpatterson/feed-archiver:py310. The major/minor version tags are only applied to the final release images and without the corresponding main branch tag, e.g. registry.gitlab.org/merpatterson/feed-archiver:py310-v0.8.

Multi-platform Docker images are published containing images for the following platforms or architectures in the Python 3.10 py310 variant:

  • linux/amd64

  • linux/arm64

  • linux/arm/v7

Usage

Create a ./.feed-archiver.yml YAML file in a directory to serve as the root directory for all feeds to be archived. The YAML file must have a top-level defaults key whose value is an object defining default or global options. In particular, the base-url key 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-level feeds key whose value is an array or list of objects defining the remote feeds to archive in this directory. Each feed object must contain a remote-url key 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-archiver command 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.html

See 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 an alternate hierarchy, such as in a media library, add a enclosures key to the feed configuration whose value is an list/array of objects each defining one alternative path to link to the feed item enclosure. Any enclosures defined in the top-level defaults key will be used for all feeds. Configuration to be shared across multiple enclosures configurations may be placed in the corresponding defaults / plugins / enclosures / {plugin_name} object. The actual linking of enclosures is delegated to plugins.

When updating the archive from the remote feed URLs using the $ feed-archiver update sub-command, the enclosures of new items are linked as configured. If the enclosures configuration changes or any of the used plugins refer to external resources that may change, such as the with the sonarr plugin when Sonarr has upgraded or renamed the corresponding video files, use the $ feed-archiver relink command to update all existing links.

Plugins

How feed item enclosures are linked into a media library is delegated to plugins or add-ons. Specifically, the plugin key in a enclosures configuration must be a string which is the name of a Python entry point registered in the feedarchiver.enclosures group. The entry point object reference must point to a feedarchiver.enclosures.EnclosurePlugin subclass which accepts the following arguments when instantiated:

  1. parent=dict

    The feedarchiver.archive.Archive if the plugin is configured in defaults for all feeds or the feedarchiver.feed.ArchiveFeed if defined for a specific feed.

  2. config=dict

    The 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:

  1. archive_feed=feedarchiver.feed.ArchiveFeed

    The object feedarchiver uses internally to represent an individual feed in the archive.

  2. feed_elem=xml.etree.ElementTree.Element, item_elem=xml.etree.ElementTree.Element

    The Python XML element object representing 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.

  3. feed_parsed=feedparser.util.FeedParserDict, item_parsed=feedparser.util.FeedParserDict

    The feedparser object representing the whole feed and the specific feed item.

  4. url_result=lxml.etree._ElementUnicodeResult

    The lmxl special string object that contains the URL of the specific enclosure. Can be used to access the specific enclosure element.

  5. enclosure_path=pathlib.Path

    The path to the enclosure in the archive as a Python pathlib.Path object 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, the Content-Type header of the response to the enclosure URL request, and finally the value of any type attribute of the enclosure element XML.

  6. match=re.Match

    The Python regular expression match object if the match-pattern matched the string expanded from the Python format string in the match-string key. Particularly useful to designate regular expression groups in the match-pattern and then use the parts of match-string that matched those groups in the format template. If the match-pattern doesn’t match then the enclosure will not be linked. If there are symbolic 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 no match-string is 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 whose fallback key is true will be applied. Here’s an example enclosures definition:

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 Plugin

If no plugin key is specified, the template plugin is used. The link path config may include the template key containing a Python format string which will be expanded to determine where the feed item enclosure should be linked to. The default template is:

./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 of the arguments passed into enclosure plugins.

Sonarr TV Series Plugin

The sonarr plugin uses values from the enclosure configuration and/or the match groups to lookup a TV series/show managed by Sonarr, then lookup an episode video file that corresponds to the feed item enclosure, and link the enclosure next to that video file. The enclosures configuration or match groups must contain:

They may also include:

  • stem-append containing a string to append to the episode file stem before the enclosure suffix/extension

Contributing

NOTE: This project is hosted on GitLab. There’s a mirror on GitHub but please use GitLab for reporting issues, submitting PRs/MRs and any other development or maintenance activity.

See the ./CONTRIBUTING.rst file for more details on how to get started with development.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

feed-archiver-2.0.4.tar.gz (463.6 kB view details)

Uploaded Source

Built Distribution

feed_archiver-2.0.4-py3-none-any.whl (49.0 kB view details)

Uploaded Python 3

File details

Details for the file feed-archiver-2.0.4.tar.gz.

File metadata

  • Download URL: feed-archiver-2.0.4.tar.gz
  • Upload date:
  • Size: 463.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/4.0.2 CPython/3.10.10

File hashes

Hashes for feed-archiver-2.0.4.tar.gz
Algorithm Hash digest
SHA256 6255be27d620d6e3ad7632f9f56db69974e71116386fe2f25838fb41f4765fe2
MD5 387e955658f39f016bf133f757e098b4
BLAKE2b-256 95cd972cd8afd80e089af071b87a01db7c76497231ef0dcb0ecdc1cf82557392

See more details on using hashes here.

File details

Details for the file feed_archiver-2.0.4-py3-none-any.whl.

File metadata

File hashes

Hashes for feed_archiver-2.0.4-py3-none-any.whl
Algorithm Hash digest
SHA256 3c724c11d8c31959bd65226fa719edb1bb81c81e4d4d67d65590470396d43562
MD5 e7c51399a5d82e9130e13c45ea0043f3
BLAKE2b-256 1d7dd103928784915b1c6ccf17426cc35dd45e79cb8350d4d6aa1e5c1eadbfc4

See more details on using hashes here.

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page