Skip to content

Instantly share code, notes, and snippets.

@denzuko
Created February 3, 2026 17:35
Show Gist options
  • Select an option

  • Save denzuko/1f115c242d0fa88e32e0660580a74e38 to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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.