radio/fetch_podcasts.rb
2026-08-29 15:58:25 -07:00

445 lines
13 KiB
Ruby
Executable file

#!/usr/bin/env jruby
# frozen_string_literal: true
require "net/http"
require "uri"
require "cgi"
require "json"
require "sqlite3"
require "rexml/document"
require "digest/md5"
require "fileutils"
require "optparse"
module RadioAutomation
ROOT = File.expand_path("..", __dir__)
CONFIG_PATH = File.join(ROOT, "config.json")
AUDIO_EXTS = [".mp3", ".m4a"]
# Resolved at runtime from config.json "storage" key
STORAGE_DIR = nil
STATE_DIR = nil
SUBS_DB = nil
PLAYED_DB = nil
PODCASTS_DIR = nil
LOGS_DIR = nil
PLAYLISTS_DIR = nil
def self.init_paths
cfg = JSON.parse(File.read(CONFIG_PATH))
@storage = File.expand_path(cfg["storage"])
self.STORAGE_DIR = @storage
self.STATE_DIR = File.join(@storage, "state")
self.SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
self.PLAYED_DB = File.join(STATE_DIR, "played.db")
self.PODCASTS_DIR = File.join(@storage, "podcasts")
self.LOGS_DIR = File.join(@storage, "logs")
self.PLAYLISTS_DIR = File.join(@storage, "playlists")
end
def self.log_info(msg)
puts "#{Time.now.iso8601} [INFO] #{msg}"
append_log("fetch.log", msg)
end
def self.log_error(msg)
puts "#{Time.now.iso8601} [ERROR] #{msg}"
append_log("fetch.log", msg)
end
def self.append_log(filename, msg)
FileUtils.mkdir_p(LOGS_DIR)
File.open(File.join(LOGS_DIR, filename), "a") { |f| f.puts msg }
rescue StandardError
nil
end
def self.load_config
JSON.parse(File.read(CONFIG_PATH))
end
def self.slugify(name)
s = name.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
s[0, 60] || "show"
end
def self.open_subs_db
db = SQLite3::Database.new(SUBS_DB)
db.results_as_hash = true
db.execute <<-SQL
CREATE TABLE IF NOT EXISTS shows (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
feed_url TEXT NOT NULL UNIQUE,
source TEXT DEFAULT 'manual',
opml_import INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)
SQL
db
end
def self.open_played_db
db = SQLite3::Database.new(PLAYED_DB)
db.results_as_hash = true
db.execute <<-SQL
CREATE TABLE IF NOT EXISTS episodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
show_slug TEXT NOT NULL,
guid TEXT NOT NULL,
title TEXT,
file_path TEXT,
duration_seconds INTEGER,
played_at TEXT,
UNIQUE(show_slug, guid)
)
SQL
db
end
def self.gpodder_sync(config)
g = config["gpodder"]
base = g["host"].chomp("/")
username = g["username"]
password = g["password"]
url = "#{base}/subscriptions/#{CGI.escape(username)}.opml"
puts "--- Syncing subscriptions from #{base} ---"
puts "Fetching subscriptions for '#{username}'..."
uri = URI.parse(url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == "https")
http.open_timeout = 30
http.read_timeout = 60
req = Net::HTTP::Get.new(uri)
req.basic_auth(username, password)
req["Accept"] = "application/x-opml, text/xml, */*"
req["User-Agent"] = "radio-automation/1.0"
resp = http.request(req)
case resp.code
when "200"
body = resp.body
if body.nil? || body.empty?
log_error("gPodder sync returned an empty body.")
return []
end
parse_opml(body)
when "401"
log_error("gPodder sync failed: 401 Unauthorized. Check username/password in config.json.")
[]
when "404"
log_error("gPodder sync failed: 404 Not Found. User may not exist or has no subscriptions.")
[]
when "400"
log_error("gPodder sync failed: 400 Bad Request.")
[]
else
log_error("gPodder sync failed: unexpected response #{resp.code}: #{resp.body.to_s[0..200]}")
[]
end
end
def self.parse_opml(xml_string)
doc = REXML::Document.new(xml_string)
shows = []
REXML::XPath.each(doc, "//outline[@xmlUrl]") do |node|
feed_url = node.attributes["xmlUrl"].to_s.strip
name = node.attributes["text"].to_s.strip
next unless feed_url =~ /\Ahttps?:\/\//
shows << { "name" => name, "feed_url" => feed_url }
end
shows
rescue REXML::ParseException => e
log_error("Failed to parse OPML XML: #{e.message}")
[]
end
def self.register_remote_shows(remote_shows)
db = open_subs_db
added = 0
remote_shows.each do |show|
slug = slugify(show["name"])
existing = db.get_first_value("SELECT slug FROM shows WHERE slug = ?", slug)
if existing.nil?
db.execute(
"INSERT INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'gpodder', 0)",
[slug, show["name"], show["feed_url"]]
)
log_info("Registered new show: #{show['name']} (#{slug})")
added += 1
end
end
db.close
added
end
def self.prune_stale_shows(remote_shows)
db = open_subs_db
remote_slugs = remote_shows.map { |s| slugify(s["name"]) }
stale = db.query_all("SELECT slug, name FROM shows WHERE source = 'gpodder' AND opml_import = 0")
removed = 0
stale.each do |row|
next if remote_slugs.include?(row["slug"])
remove_show_data(row["slug"])
db.execute("DELETE FROM shows WHERE slug = ?", [row["slug"]])
log_info("Pruned stale show: #{row['name']} (#{row['slug']})")
removed += 1
end
db.close
removed
end
def self.extract_duration(entry_xml)
if (m = entry_xml.match(/media:duration[^>]*content="([^"]+)"/))
val = m[1]
return val.to_i if val =~ /\A\d+\z/
if (iso = val.match(/\APT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/))
h, mn, s = iso.captures.compact.map(&:to_i)
return (h || 0) * 3600 + (mn || 0) * 60 + (s || 0)
end
end
if (m = entry_xml.match(/enclosure[^>]*length="(\d+)"/))
bytes = m[1].to_i
return (bytes * 8 / 128_000) if bytes > 0
end
nil
end
def self.fetch_feed(feed_url)
uri = URI.parse(feed_url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == "https")
http.open_timeout = 30
http.read_timeout = 60
req = Net::HTTP::Get.new(uri)
req["User-Agent"] = "radio-automation/1.0"
resp = http.request(req)
raise "Feed HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
resp.body
rescue StandardError => e
log_error("Feed fetch error for #{feed_url}: #{e.message}")
nil
end
def self.download_episode(url, dest_dir, filename)
FileUtils.mkdir_p(dest_dir)
dest = File.join(dest_dir, filename)
return dest if File.exist?(dest)
uri = URI.parse(url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == "https")
http.open_timeout = 30
http.read_timeout = 120
req = Net::HTTP::Get.new(uri)
req["User-Agent"] = "radio-automation/1.0"
tmp = "#{dest}.part"
begin
http.request(req) do |resp|
raise "Download HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
File.open(tmp, "wb") do |f|
resp.read_body { |chunk| f.write(chunk) }
end
end
File.rename(tmp, dest)
dest
rescue StandardError => e
log_error("Download failed for #{url}: #{e.message}")
File.delete(tmp) if File.exist?(tmp)
nil
end
end
def self.safe_filename(title, fallback)
name = title.to_s.gsub(/[^\w\s.\-]/, "").strip.tr(" ", "_")[0, 120]
"#{name || fallback}.mp3"
end
def self.fetch_show_episodes(slug, name, feed_url)
dest_dir = File.join(PODCASTS_DIR, slug)
raw = fetch_feed(feed_url)
return 0 if raw.nil?
played_db = open_played_db
seen = played_db.query("SELECT guid FROM episodes WHERE show_slug = ?", slug).map { |r| r["guid"] }
subs_db = open_subs_db
new_count = 0
raw.scan(/<(?:item|entry)[^>]*>(.*?)<\/(?:item|entry)>/mi).each do |(entry_xml)|
guid_m = entry_xml.match(/<guid[^>]*>([^<]*)<\/guid>|<id>([^<]*)<\/id>|<link[^>]*href="([^"]+)"/i)
guid = guid_m ? (guid_m[1] || guid_m[2] || guid_m[3]).strip : Digest::MD5.hexdigest(entry_xml[0, 200])
next if seen.include?(guid)
enc_m = entry_xml.match(/enclosure[^>]*url="([^"]+)"/i)
next unless enc_m
audio_url = enc_m[1]
title_m = entry_xml.match(/<title[^>]*>([^<]*)<\/title>/i)
title = title_m ? title_m[1].strip : "untitled"
filename = safe_filename(title, guid[-20..])
file_path = download_episode(audio_url, dest_dir, filename)
next if file_path.nil?
duration = extract_duration(entry_xml)
played_db.execute(
"INSERT OR IGNORE INTO episodes (show_slug, guid, title, file_path, duration_seconds, played_at) VALUES (?, ?, ?, ?, ?, NULL)",
[slug, guid, title, file_path, duration]
)
new_count += 1
log_info(" New episode: #{title} [#{filename}]")
end
played_db.close
subs_db.close
new_count
end
def self.fetch_all_episodes
db = open_subs_db
shows = db.query_all("SELECT slug, name, feed_url FROM shows ORDER BY name")
db.close
total_new = 0
shows.each do |show|
log_info("--- Fetching: #{show['name']} (#{show['slug']}) ---")
begin
total_new += fetch_show_episodes(show["slug"], show["name"], show["feed_url"])
rescue StandardError => e
log_error("Unexpected error fetching #{show['slug']}: #{e.message}")
end
end
log_info("=== Fetch complete: #{total_new} new episode(s) ===")
end
def self.list_shows(detail: false)
db = open_subs_db
rows = db.query_all("SELECT slug, name, feed_url, source, opml_import FROM shows ORDER BY name")
db.close
if rows.empty?
puts "No shows registered."
return
end
puts format("%-30s %-10s %s", "SLUG", "PROTECTED", "NAME")
rows.each do |r|
prot = r["opml_import"] ? "yes" : "no"
line = format("%-30s %-10s %s", r["slug"], prot, r["name"])
line += "\n" + (" " * 50) + r["feed_url"] if detail
puts line
end
end
def self.add_show(feed_url)
raw = fetch_feed(feed_url)
if raw.nil?
log_error("Could not fetch feed: #{feed_url}")
return
end
title_m = raw.match(/<channel[^>]*>\s*<title[^>]*>([^<]*)<\/title>/im) ||
raw.match(/<feed[^>]*>\s*<title[^>]*>([^<]*)<\/title>/im)
if title_m.nil?
log_error("Could not determine show title from #{feed_url}")
return
end
name = title_m[1].strip
slug = slugify(name)
db = open_subs_db
db.execute(
"INSERT OR IGNORE INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'manual', 0)",
[slug, name, feed_url]
)
db.close
log_info("Added show: #{name} (#{slug})")
fetch_show_episodes(slug, name, feed_url)
end
def self.remove_show_data(slug)
pod_dir = File.join(PODCASTS_DIR, slug)
FileUtils.rm_rf(pod_dir) if Dir.exist?(pod_dir)
pls = File.join(PLAYLISTS_DIR, "#{slug}.pls")
File.delete(pls) if File.exist?(pls)
end
def self.delete_show(slug)
db = open_subs_db
row = db.get_first_hash("SELECT name FROM shows WHERE slug = ?", slug)
if row.nil?
log_error("No show found with slug '#{slug}'.")
return
end
remove_show_data(slug)
db.execute("DELETE FROM shows WHERE slug = ?", [slug])
db.close
played_db = open_played_db
played_db.execute("DELETE FROM episodes WHERE show_slug = ?", [slug])
played_db.close
log_info("Deleted show: #{row['name']} (#{slug})")
end
def self.import_opml(path)
content = File.read(path)
shows = parse_opml(content)
db = open_subs_db
shows.each do |show|
slug = slugify(show["name"])
db.execute(
"INSERT OR IGNORE INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'opml', 1)",
[slug, show["name"], show["feed_url"]]
)
end
db.close
log_info("OPML import: #{shows.size} show(s) processed.")
end
def self.run_fetch(config)
g = config["gpodder"]
if g["enable"] == true
remote = gpodder_sync(config)
if remote.empty?
log_info("No subscriptions retrieved from gPodder; using local registry only.")
else
added = register_remote_shows(remote)
pruned = prune_stale_shows(remote)
log_info("Sync: #{added} added, #{pruned} pruned.")
end
end
fetch_all_episodes
end
def self.main
options = {}
OptionParser.new do |opts|
opts.banner = "Usage: fetch_podcasts.rb [options]"
opts.on("--list-shows", "List registered shows") { options[:list] = true }
opts.on("--detail", "With --list-shows, show feed URLs") { options[:detail] = true }
opts.on("--add-show FEED_URL", "Add a show from a feed URL") { |v| options[:add] = v }
opts.on("--delete-show SLUG", "Delete a show and its data") { |v| options[:delete] = v }
opts.on("--import-opml FILE", "Import shows from an OPML file") { |v| options[:import] = v }
end.parse!
init_paths
FileUtils.mkdir_p(STATE_DIR)
FileUtils.mkdir_p(LOGS_DIR)
FileUtils.mkdir_p(PODCASTS_DIR)
FileUtils.mkdir_p(PLAYLISTS_DIR)
config = load_config
if options[:list]
list_shows(detail: options[:detail])
elsif options[:add]
add_show(options[:add])
elsif options[:delete]
delete_show(options[:delete])
elsif options[:import]
import_opml(options[:import])
else
run_fetch(config)
end
end
end
RadioAutomation.main