Created
February 3, 2026 17:35
-
-
Save denzuko/1f115c242d0fa88e32e0660580a74e38 to your computer and use it in GitHub Desktop.
podcast mirror program in csh. Depends: *nix, xmlstarlet, and curl. Supports XDG. Use with periodic or cron.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/tcsh | |
| ## | |
| # Podcast sync script | |
| # version 1.0 | |
| # Copyright (C)2026 Tony Spencer <denzuko@dapla.net>. All Rights Reserved. | |
| # Licenced for use and distribution under the BSD 2-clause licence. | |
| ## | |
| environment: | |
| set CONFIG_DIR = "$HOME/.config/podcasts" | |
| if ( $?XDG_CONFIG_HOME ) then | |
| set CONFIG_DIR = "$XDG_CONFIG_HOME/podcasts" | |
| endif | |
| set DEST_DIR = "$HOME/Downloads/Podcasts" | |
| if ( $?XDG_DOWNLOAD_DIR ) then | |
| set DEST_DIR = "$XDG_DOWNLOAD_DIR/Podcasts" | |
| endif | |
| set CONFIG_FILE = "$CONFIG_DIR/feeds.xml" | |
| set CONFIG_SCHEMA = "$CONFIG_DIR/feeds.xsd" | |
| set CONFIG_STYLE = "$CONFIG_DIR/feeds.xsl" | |
| set PLAYLIST_FILE = "$DEST_DIR/podcasts.m3u" | |
| set AGENT = "MetisAI/1.0 (MetisAi/PodcastFeed +https://3umgroup.com/ai/)" | |
| # -- Initialize Flags --- | |
| set RECENT_ONLY = false | |
| set MAKE_PLAYLIST = false | |
| set ONLY_THIS = "" | |
| set FORCE = false | |
| set DEBUG = false | |
| set VERBOSE = false | |
| # --- Time Variables --- | |
| set CUR_DAY = `date +%a` # e.g., Tue | |
| set CUR_HOUR = `date +%H` # e.g., 11 | |
| set CUR_MIN = `date +%M` #3 e.g., 30 | |
| arguments: | |
| while ( $#argv > 0 ) | |
| switch ( $1 ) | |
| case "-h": | |
| case "--help": | |
| goto usage | |
| case "-r": | |
| case "--recent": | |
| set RECENT_ONLY = true | |
| breaksw | |
| case "-p": | |
| case "--playlist": | |
| set MAKE_PLAYLIST = true | |
| breaksw | |
| case "-f": | |
| case "--force": | |
| set FORCE = true | |
| breaksw | |
| case "-d": | |
| case "--debug": | |
| set DEBUG = true | |
| breaksw | |
| case "-v": | |
| case "--verbose": | |
| set VERBOSE = true | |
| breaksw | |
| case "-t": | |
| case "--title": | |
| shift | |
| if ( ! $#argv ) then | |
| echo "Error: --title requires an argument." | |
| goto usage | |
| endi | |
| set ONLY_THIS = $1 | |
| breaksw | |
| case "-a": | |
| case "--agent": | |
| shift | |
| if ( ! $#argv ) then | |
| echo "Error: --agent requires an argument." | |
| goto usage | |
| endi | |
| set AGENT = $1 | |
| breaksw | |
| default: | |
| echo "Unknown option: $1" | |
| goto usage | |
| endsw | |
| shift | |
| end | |
| main: | |
| if ( true == $DEBUG ) then | |
| unset notify | |
| unset printexitvalue | |
| set verbose | |
| endif | |
| onintr finish | |
| alias lint 'set _tmp = `mktemp`; \ | |
| xmlstarlet sel -N xs="http://www.w3.org/2001/XMLSchema" -t -c "//xs:schema" \!:1 > $_tmp && \ | |
| xmlstarlet val -e -s $_tmp \!:1; \ | |
| rm -f $_tmp; \ | |
| unset _tmp' | |
| if ( ! -d "$CONFIG_DIR" ) mkdir -p "$CONFIG_DIR" | |
| if ( ! -f "$CONFIG_FILE" ) tee "$CONFIG_FILE" >/dev/null << EOL | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE subscriptions [ | |
| <!ELEMENT subscriptions (podcast*)> | |
| <!ELEMENT podcast EMPTY> | |
| <!ATTLIST podcast | |
| title CDATA #REQUIRED | |
| url CDATA #REQUIRED | |
| scope CDATA #REQUIRED | |
| day CDATA #REQUIRED | |
| pull_time CDATA #REQUIRED> | |
| ]> | |
| <?xml-stylesheet type="text/xsl" href="feeds.xsl"?> | |
| <subscriptions> | |
| <podcast title="Daily News" url="https://feeds.npr.org/510289/podcast.xml" scope="latest" day="Daily" pull_time="06" /> | |
| <podcast title="Weekly Tech" url="https://lexfridman.com/feed/podcast/" scope="all" day="Tue" pull_time="11" /> | |
| <podcast title="2600 Off The Hook" url="https://2600.com/oth-broadband.xml" scope="latest" day="Tue" pull_time="18" /> | |
| <podcast title="2600 Off The Wall" url="https://2600.com/otw-broadband.xml" scope="latest" day="Wed" pull_time="18" /> | |
| </subscriptions> | |
| EOL | |
| if ( ! -f "$CONFIG_SCHEMA" ) tee "$CONFIG_SCHEMA" > /dev/null << EOL | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> | |
| <xs:element name="subscriptions"> | |
| <xs:complexType> | |
| <xs:sequence> | |
| <xs:element name="podcast" maxOccurs="unbounded" minOccurs="1"> | |
| <xs:complexType> | |
| <xs:attribute name="title" type="xs:string" use="required" /> | |
| <xs:attribute name="scope"> | |
| <xs:simpleType> | |
| <xs:restriction base="xs:string"> | |
| <xs:enumeration value="all"/> | |
| <xs:enumeration value="latest"/> | |
| <xs:enumeration value="none"/> | |
| </xs:restriction> | |
| </xs:simpleType> | |
| </xs:attribute> | |
| <xs:attribute name="url" type="xs:anyURI" use="required" /> | |
| <xs:attribute name="day"> | |
| <xs:simpleType> | |
| <xs:restriction base="xs:string"> | |
| <xs:enumeration value="Daily"/> | |
| <xs:enumeration value="Mon"/> | |
| <xs:enumeration value="Tue"/> | |
| <xs:enumeration value="Wed"/> | |
| <xs:enumeration value="Thu"/> | |
| <xs:enumeration value="Fri"/> | |
| <xs:enumeration value="Sat"/> | |
| <xs:enumeration value="Sun"/> | |
| </xs:restriction> | |
| </xs:simpleType> | |
| </xs:attribute> | |
| <xs:attribute name="pull_time"> | |
| <xs:simpleType> | |
| <xs:restriction base="xs:string"> | |
| <xs:enumeration value="00"/> | |
| <xs:enumeration value="01"/> | |
| <xs:enumeration value="02"/> | |
| <xs:enumeration value="03"/> | |
| <xs:enumeration value="04"/> | |
| <xs:enumeration value="05"/> | |
| <xs:enumeration value="06"/> | |
| <xs:enumeration value="07"/> | |
| <xs:enumeration value="08"/> | |
| <xs:enumeration value="09"/> | |
| <xs:enumeration value="10"/> | |
| <xs:enumeration value="11"/> | |
| <xs:enumeration value="12"/> | |
| <xs:enumeration value="13"/> | |
| <xs:enumeration value="14"/> | |
| <xs:enumeration value="15"/> | |
| <xs:enumeration value="16"/> | |
| <xs:enumeration value="17"/> | |
| <xs:enumeration value="18"/> | |
| <xs:enumeration value="19"/> | |
| <xs:enumeration value="20"/> | |
| <xs:enumeration value="21"/> | |
| <xs:enumeration value="22"/> | |
| <xs:enumeration value="23"/> | |
| </xs:restriction> | |
| </xs:simpleType> | |
| </xs:attribute> | |
| </xs:complexType> | |
| </xs:element> | |
| </xs:sequence> | |
| </xs:complexType> | |
| </xs:element> | |
| </xs:schema> | |
| EOL | |
| if ( ! -f "$CONFIG_STYLE" ) tee "$CONFIG_STYLE" >/dev/null << EOL | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> | |
| <xsl:template match="/"> | |
| <html> | |
| <body style="font-family:sans-serif; background:#f4f4f4; padding:20px;"> | |
| <h2>Podcast Subscriptions</h2> | |
| <table border="1" style="border-collapse:collapse; width:100%; background:white;"> | |
| <tr style="background:#ddd;"> | |
| <th style="padding:10px;">Title</th> | |
| <th>Schedule (Day @ Time)</th> | |
| <th>RSS URL</th> | |
| </tr> | |
| <xsl:for-each select="subscriptions/podcast"> | |
| <tr> | |
| <td style="padding:10px;"><xsl:value-of select="@title"/></td> | |
| <td style="text-align:center;"><xsl:value-of select="@day"/> @ <xsl:value-of select="@pull_time"/></td> | |
| <td><a href="{@url}"><xsl:value-of select="@url"/></a></td> | |
| </tr> | |
| </xsl:for-each> | |
| </table> | |
| </body> | |
| </html> | |
| </xsl:template> | |
| </xsl:stylesheet> | |
| EOL | |
| if ( ! -d "$DEST_DIR" ) mkdir -p "$DEST_DIR" | |
| xmlstarlet val -e -s $CONFIG_SCHEMA $CONFIG_FILE | |
| if ( 0 != $status ) then | |
| if ( true == $VERBOSE ) echo "Error processing config file" | |
| kill -TERM $$ | |
| endif | |
| if ( true == $VERBOSE ) echo "Current System Time: $CUR_DAY at ${CUR_HOUR}:${CUR_MIN}" | |
| # --- Logic: Filter Feeds by Day AND Time ( AND title if flag is set ) --- | |
| set XPATH_FILTER = "//podcast[(@day='Daily' or @day='$CUR_DAY') and @pull_time<='$CUR_HOUR']/@url" | |
| if ( true == $FORCE ) set XPATH_FILTER = "//podcast/@url" | |
| if (($?ONLY_THIS) && ("" != "$ONLY_THIS" )) then | |
| set XPATH_FILTER = "//podcast[(@day='Daily' or @day='$CUR_DAY') and @title='$ONLY_THIS' and @pull_time<='$CUR_HOUR']/@url" | |
| if ( true == $FORCE ) set XPATH_FILTER = "//podcast[@title='$ONLY_THIS']/@url" | |
| endif | |
| set FEEDS = `xmlstarlet sel -t -v "$XPATH_FILTER" "$CONFIG_FILE"` | |
| if ( "" == "$FEEDS" ) then | |
| if ( true == $VERBOSE ) echo "No podcasts scheduled for this time slot." | |
| kill -TERM $$ | |
| endif | |
| set temp_files = "" | |
| foreach feed ( $FEEDS:q ) | |
| set title = `xmlstarlet sel -t -v "//podcast[@url='$feed']/@title" "$CONFIG_FILE"` | |
| if (0 != $status) then | |
| if ( true == $VERBOSE ) echo "Error parsing config: $status" | |
| continue | |
| endif | |
| set scope = `xmlstarlet sel -t -v "//podcast[@url='$feed']/@scope" "$CONFIG_FILE"` | |
| if (0 != $status) then | |
| if ( true == $VERBOSE ) echo "Error parsing config: $status" | |
| continue | |
| endif | |
| if ( true == $VERBOSE ) echo ">>> Syncing: $title" | |
| switch ($scope:q) | |
| case "none": | |
| continue | |
| breaksw | |
| case "all": | |
| set ep_xpath = "//enclosure/@url" | |
| breaksw | |
| case "latest": | |
| default: | |
| set ep_xpath = "(//enclosure)[1]/@url" | |
| breaksw | |
| endsw | |
| if ( true == $RECENT_ONLY ) set ep_xpath = "(//enclosure)[1]/@url" | |
| set tmp_xml = `mktemp` | |
| set temp_files = ( $temp_files $tmp_xml ) | |
| if (true == $DEBUG ) echo $tmp_xml | |
| curl -A "$AGENT" -sLk "$feed:q" -o "$tmp_xml" | |
| if (0 != $status) then | |
| if ( true == $VERBOSE ) echo "Error retreiving feed: $feed:q" | |
| continue | |
| endif | |
| set mp3_urls = `xmlstarlet sel -t -v "$ep_xpath" "$tmp_xml"` | |
| if ("" == "$mp3_urls" ) then | |
| if ( true == $VERBOSE ) echo "No urls found from rss feed" | |
| continue | |
| endif | |
| foreach mp3_url ( $mp3_urls:q ) | |
| set filename = `echo "$mp3_url" | cut -d'?' -f1 | xargs basename` | |
| if ( ! -f "$DEST_DIR/$filename" ) then | |
| if ( true == $VERBOSE ) echo " Downloading: $filename" | |
| curl -A "$AGENT" -sLk "$mp3_url" -o "$DEST_DIR/$filename" | |
| if (0 != $status) then | |
| if ( true == $VERBOSE ) echo "Error retreiving audio file: $mp3_url" | |
| continue | |
| endif | |
| endif | |
| end | |
| end | |
| if ( true == $MAKE_PLAYLIST ) goto playlist | |
| kill -TERM $$ | |
| playlist: | |
| if ( true == $VERBOSE ) echo "Updating playlist: $PLAYLIST_FILE" | |
| echo "#EXTM3U" > "$PLAYLIST_FILE" | |
| find "$DEST_DIR" -maxdepth 1 -name "*.mp3" -printf "%T@ %p\n" | awk -v out="$PLAYLIST_FILE" '\ | |
| { a[$1] = substr($0, index($0,$2)) } \ | |
| END { n = asorti(a, b, "@ind_num_asc"); for (i=1; i<=n; i++) print a[b[i]] >> out}' | |
| finish: | |
| foreach tmpfile ( $temp_files ) | |
| if ( -f $tmpfile) rm -f $tmpfile | |
| end | |
| exit 0 | |
| usage: | |
| cat - << EOL | |
| Usage: $0 [options] | |
| Options: | |
| -r, --recent Download recent episode only [overloads scope] | |
| -p, --playlist Create a m3u playlist | |
| -t, --title Pull only this podcast (requires title) | |
| -f, --force Ignore scheduled time | |
| -h, --help Show this message | |
| EOL | |
| exit 1 |
Comments are disabled for this gist.