mirror of
https://github.com/mistergibson/radio.git
synced 2026-09-08 22:09:51 -07:00
504 lines
13 KiB
Ruby
Executable file
504 lines
13 KiB
Ruby
Executable file
#!/usr/bin/env jruby
|
|
# frozen_string_literal: true
|
|
|
|
require "net/http"
|
|
require "uri"
|
|
require "json"
|
|
require "digest/sha1"
|
|
require "sequel"
|
|
require "nokogiri"
|
|
|
|
SCRIPT_DIR = File.expand_path(File.dirname(__FILE__))
|
|
CONFIG_PATH = File.join(SCRIPT_DIR, "config.json")
|
|
|
|
def load_config
|
|
JSON.parse(File.read(CONFIG_PATH))
|
|
end
|
|
|
|
CFG = load_config
|
|
STORAGE = CFG["storage"]
|
|
STATE_DIR = File.join(STORAGE, "state")
|
|
LOGS_DIR = File.join(STORAGE, "logs")
|
|
PODCAST_DIR = File.join(STORAGE, "podcasts")
|
|
SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
|
|
PLAYED_DB = File.join(STATE_DIR, "played.db")
|
|
LOG_FILE = File.join(LOGS_DIR, "fetch_podcasts.log")
|
|
|
|
$global_log_dir = LOGS_DIR
|
|
|
|
def ensure_dir(path)
|
|
return if Dir.exist?(path)
|
|
parent = File.dirname(path)
|
|
ensure_dir(parent) unless Dir.exist?(parent) && parent != path
|
|
Dir.mkdir(path)
|
|
rescue Errno::EEXIST
|
|
nil
|
|
end
|
|
|
|
ensure_dir(STATE_DIR)
|
|
ensure_dir(LOGS_DIR)
|
|
ensure_dir(PODCAST_DIR)
|
|
|
|
$db_s = Sequel.connect("jdbc:sqlite:" + SUBS_DB)
|
|
$db_p = Sequel.connect("jdbc:sqlite:" + PLAYED_DB)
|
|
|
|
$db_s.execute("PRAGMA journal_mode=WAL;")
|
|
$db_s.execute("PRAGMA busy_timeout=5000;")
|
|
$db_p.execute("PRAGMA journal_mode=WAL;")
|
|
$db_p.execute("PRAGMA busy_timeout=5000;")
|
|
|
|
def log(level, msg)
|
|
ts = Time.now.strftime("%Y-%m-%d %H:%M:%S")
|
|
line = "[#{ts}] [#{level}] #{msg}"
|
|
puts line
|
|
begin
|
|
File.open(LOG_FILE, "a") { |f| f.puts(line) }
|
|
rescue StandardError => e
|
|
warn "Log write failed: #{e.class} #{e.message}"
|
|
end
|
|
end
|
|
|
|
def table_count(db, tbl)
|
|
db[:sqlite_master].where(type: 'table', name: tbl).count > 0
|
|
end
|
|
|
|
def ensure_schema
|
|
unless table_count($db_s, "shows")
|
|
$db_s.execute(%(
|
|
CREATE TABLE IF NOT EXISTS shows (
|
|
guid TEXT PRIMARY KEY,
|
|
slug TEXT UNIQUE NOT NULL,
|
|
title TEXT NOT NULL,
|
|
feed_url TEXT NOT NULL,
|
|
archive INTEGER DEFAULT 0,
|
|
opml_import INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
))
|
|
log("INFO", "Created shows table in subscriptions.db")
|
|
end
|
|
|
|
unless table_count($db_p, "episodes")
|
|
$db_p.execute(%(
|
|
CREATE TABLE IF NOT EXISTS episodes (
|
|
guid TEXT PRIMARY KEY,
|
|
show_guid TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
duration_seconds INTEGER DEFAULT 0,
|
|
played INTEGER DEFAULT 0,
|
|
local_path TEXT,
|
|
file_size_bytes INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
))
|
|
log("INFO", "Created episodes table in played.db")
|
|
end
|
|
end
|
|
|
|
ensure_schema
|
|
|
|
def gen_guid(seed_str)
|
|
Digest::SHA1.hexdigest(seed_str.to_s)
|
|
end
|
|
|
|
def generate_slug(title)
|
|
title.to_s.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/^_+|_+$/, "")
|
|
end
|
|
|
|
def http_stream_to_file(url, dest_path, user = nil, pass = nil)
|
|
uri = URI.parse(url)
|
|
success = false
|
|
tmp_dest = "#{dest_path}.downloading"
|
|
|
|
begin
|
|
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.request_uri)
|
|
req.add_field("User-Agent", "RadioAutomation/1.0 (JRuby)")
|
|
req.basic_auth(user, pass) if user && !user.empty?
|
|
|
|
res = nil
|
|
http.start do |conn|
|
|
conn.request(req) do |response|
|
|
res = response
|
|
end
|
|
end
|
|
|
|
if res.is_a?(Net::HTTPRedirection)
|
|
loc = res["location"]
|
|
log("WARN", "Redirect to: #{loc}")
|
|
return http_stream_to_file(loc, dest_path, user, pass)
|
|
end
|
|
|
|
unless res.is_a?(Net::HTTPSuccess)
|
|
raise "HTTP #{res.code}: #{res.message}"
|
|
end
|
|
|
|
File.open(tmp_dest, "wb") do |f|
|
|
res.read_body { |chunk| f.write(chunk) }
|
|
end
|
|
File.rename(tmp_dest, dest_path)
|
|
success = true
|
|
rescue Exception => error
|
|
log("ERROR", "Download failed for #{url}: #{error.class}: #{error.message}")
|
|
begin
|
|
File.delete(tmp_dest) if File.exist?(tmp_dest)
|
|
rescue StandardError
|
|
# ignore cleanup errors
|
|
end
|
|
end
|
|
success
|
|
end
|
|
|
|
def parse_feed(opts)
|
|
file_path = opts[:file_path] || raise("parse_feed: :file_path is required")
|
|
feed_url = opts[:feed_url] || ""
|
|
|
|
doc = Nokogiri::XML(File.read(file_path), nil, XML::NO_NETWORK)
|
|
channel = doc.at_xpath("//channel")
|
|
raise "No <channel> element found in feed" unless channel
|
|
|
|
items = []
|
|
channel.elements("item").each do |item|
|
|
enc = item.at_xpath("./enclosure")
|
|
next unless enc
|
|
next unless enc.attributes["type"].value =~ /audio/i
|
|
|
|
pub_date_raw = item.at_xpath("./pubDate")&.text
|
|
published_at = begin
|
|
Time.parse(pub_date_raw)&.utc&.strftime("%Y-%m-%d %H:%M:%S")
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
|
|
dur_el = item.at_xpath(".//media:duration", "media" => "http://search.yahoo.com/mrss/")
|
|
dur_sec = 0
|
|
if dur_el
|
|
d = dur_el.text.strip
|
|
if d.include?(":")
|
|
parts = d.split(":").map(&:to_i)
|
|
dur_sec = parts[0] * 3600 + parts[1] * 60 + parts[2]
|
|
else
|
|
dur_sec = d.to_i
|
|
end
|
|
end
|
|
|
|
enc_len = enc.attributes["length"]&.value&.to_i || 0
|
|
est_dur = (enc_len / (128 * 1024)).round if enc_len > 0
|
|
|
|
items << {
|
|
guid: item.at_xpath("./guid")&.text.presence || gen_guid(enc.attributes["url"].value),
|
|
title: item.at_xpath("./title")&.text || "Untitled",
|
|
url: enc.attributes["url"].value,
|
|
duration_seconds: dur_sec > 0 ? dur_sec : (est_dur || 0),
|
|
file_size_bytes: enc_len,
|
|
published_at: published_at
|
|
}
|
|
end
|
|
|
|
{
|
|
title: channel.at_xpath("./title")&.text || "Unknown Show",
|
|
feed_url: feed_url,
|
|
items: items
|
|
}
|
|
end
|
|
|
|
def register_show(show_title, feed_url, archive: false, opml_import: false)
|
|
title = show_title.to_s.strip
|
|
url = feed_url.to_s.strip
|
|
return nil if title.empty? || url.empty?
|
|
|
|
slug = title.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/^_+|_+$/, "")
|
|
slug = "show" if slug.empty?
|
|
|
|
# Check for slug collision with a different feed URL
|
|
existing_slug = $db_s[:shows].where(slug: slug).first
|
|
if existing_slug && existing_slug[:feed_url] != url
|
|
# Append a short hash of the URL to disambiguate
|
|
suffix = Digest::SHA1.hexdigest(url)[0, 6]
|
|
slug = "#{slug}_#{suffix}"
|
|
end
|
|
|
|
guid = gen_guid(url)
|
|
now = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
existing = $db_s[:shows].where(guid: guid).first
|
|
if existing
|
|
$db_s[:shows].where(guid: guid).update(
|
|
title: title,
|
|
slug: slug,
|
|
feed_url: url,
|
|
archive: archive ? 1 : 0,
|
|
opml_import: opml_import ? 1 : 0
|
|
)
|
|
else
|
|
$db_s[:shows].insert(
|
|
guid: guid,
|
|
slug: slug,
|
|
title: title,
|
|
feed_url: url,
|
|
archive: archive ? 1 : 0,
|
|
opml_import: opml_import ? 1 : 0,
|
|
created_at: now
|
|
)
|
|
end
|
|
|
|
log("INFO", "Registered: #{title} (#{slug})")
|
|
true
|
|
end
|
|
|
|
def sync_gpodder
|
|
gpodder_cfg = CFG["gpodder"] || {}
|
|
return unless gpodder_cfg["enabled"] == true
|
|
|
|
host = gpodder_cfg["host"].to_s.sub(/\Ahttps?:\/\//, "").sub(/\/.*\z/, "")
|
|
username = gpodder_cfg["username"].to_s
|
|
password = gpodder_cfg["password"].to_s
|
|
device_id = gpodder_cfg["device_id"].to_s
|
|
return if username.empty? || password.empty? || device_id.empty?
|
|
|
|
full_host = "https://#{host}"
|
|
opml_url = "#{full_host}/subscriptions/#{username}/#{device_id}.opml"
|
|
log("INFO", "gPodder sync: fetching OPML from #{opml_url}")
|
|
|
|
tmp_opml = "/tmp/radio_gp_odder_sync.opml"
|
|
ok = http_stream_to_file(opml_url, tmp_opml, username, password)
|
|
unless ok
|
|
log("ERROR", "gPodder sync: failed to download #{opml_url}")
|
|
return
|
|
end
|
|
|
|
opml_doc = Nokogiri::XML(File.read(tmp_opml), nil, XML::NO_NETWORK)
|
|
remote_shows = []
|
|
opml_doc.xpath("//outline[@xmlUrl]").each do |o|
|
|
xml_url = o.attr("xmlUrl").to_s.strip
|
|
title = o.attr("title").to_s.strip
|
|
next if xml_url.empty?
|
|
next unless xml_url =~ /\.(rss|atom)(\?.*)?\z/i
|
|
remote_shows << { url: xml_url, title: title }
|
|
end
|
|
|
|
added = 0
|
|
removed = 0
|
|
|
|
remote_shows.each do |rs|
|
|
existing = $db_s[:shows].where(feed_url: rs[:url]).first
|
|
if existing.nil?
|
|
row = register_show(rs[:title], rs[:url], 0, 0)
|
|
added += 1 if row
|
|
end
|
|
end
|
|
|
|
all_local = $db_s[:shows].all
|
|
all_local.each do |row|
|
|
if row[:opml_import] == 0
|
|
still_remote = remote_shows.any? { |rs| rs[:url] == row[:feed_url] }
|
|
unless still_remote
|
|
$db_p[:episodes].where(show_guid: row[:guid]).delete
|
|
$db_s[:shows].where(guid: row[:guid]).delete
|
|
log("INFO", "gPodder sync: pruned '#{row[:slug]}'")
|
|
removed += 1
|
|
end
|
|
end
|
|
end
|
|
|
|
log("INFO", "gPodder sync: #{added} added, #{removed} removed")
|
|
begin
|
|
File.delete(tmp_opml)
|
|
rescue StandardError
|
|
# ignore cleanup errors
|
|
end
|
|
end
|
|
|
|
def cmd_add_show(args)
|
|
if args.size < 1
|
|
log("ERROR", "Usage: fetch_podcasts.rb --add-show <url>")
|
|
exit 1
|
|
end
|
|
url = args.first
|
|
title = url
|
|
parsed = nil
|
|
|
|
tmp_feed = "/tmp/radio_feed_probe.xml"
|
|
if http_stream_to_file(url, tmp_feed)
|
|
parsed = parse_feed({:file_path => tmp_feed, :feed_url => url})
|
|
title = parsed[:title] if parsed && !parsed[:title].empty?
|
|
begin
|
|
File.delete(tmp_feed)
|
|
rescue StandardError
|
|
# ignore cleanup errors
|
|
end
|
|
end
|
|
|
|
row = register_show(title, url)
|
|
if row
|
|
log("INFO", "Added show: #{row[:slug]} (#{row[:title]})")
|
|
else
|
|
log("ERROR", "Failed to add show: #{url}")
|
|
exit 1
|
|
end
|
|
end
|
|
|
|
def cmd_remove_show(slug_or_url)
|
|
row = $db_s[:shows].where(Sequel.function("lower", Sequel.val(:slug)) =~ "%#{slug_or_url}%" ).first
|
|
row ||= $db_s[:shows].where(feed_url: slug_or_url).first
|
|
if row.nil?
|
|
log("ERROR", "Show not found: #{slug_or_url}")
|
|
exit 1
|
|
end
|
|
$db_p[:episodes].where(show_guid: row[:guid]).delete
|
|
$db_s[:shows].where(guid: row[:guid]).delete
|
|
log("INFO", "Removed show: #{row[:slug]} and its episodes")
|
|
end
|
|
|
|
def cmd_archive(slug_or_url, flag)
|
|
row = $db_s[:shows].where(slug: slug_or_url).first
|
|
row ||= $db_s[:shows].where(feed_url: slug_or_url).first
|
|
if row.nil?
|
|
log("ERROR", "Show not found: #{slug_or_url}")
|
|
exit 1
|
|
end
|
|
val = flag ? 1 : 0
|
|
$db_s[:shows].where(guid: row[:guid]).update(archive: val)
|
|
log("INFO", "#{flag ? 'Archived' : 'Unarchived'}: #{row[:slug]}")
|
|
end
|
|
|
|
def cmd_import_opml(path)
|
|
opml_file = path
|
|
raise "File not found: #{opml_file}" unless File.exist?(opml_file)
|
|
|
|
doc = Nokogiri::XML(File.read(opml_file)) { |config| config.nonet }
|
|
|
|
outlines = doc.xpath("//outline")
|
|
count = 0
|
|
outlines.each do |ol|
|
|
url = ol.attr("xmlUrl") || ol.attr("url")
|
|
title = ol.attr("title") || "Unknown"
|
|
next if url.nil? || url.empty?
|
|
|
|
existing = $db_s[:shows].where(feed_url: url).first
|
|
if existing
|
|
log("info", "OPML: '#{existing[:slug]}' already registered, skipping")
|
|
next
|
|
end
|
|
|
|
slug = gen_guid(url)[0..11]
|
|
guid = gen_guid(url)
|
|
$db_s[:shows].insert(
|
|
guid: guid,
|
|
slug: slug,
|
|
title: title,
|
|
feed_url: url,
|
|
archive: 1,
|
|
opml_import: 1,
|
|
created_at: Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
|
|
)
|
|
count += 1
|
|
log("info", "OPML: registered '#{slug}' (#{title})")
|
|
end
|
|
|
|
log("info", "Imported #{count} shows from OPML")
|
|
end
|
|
|
|
def cmd_list(detail)
|
|
rows = $db_s[:shows].order(:slug).all
|
|
if rows.empty?
|
|
puts "No shows registered."
|
|
return
|
|
end
|
|
if detail
|
|
rows.each do |r|
|
|
ep_count = $db_p[:episodes].where(show_guid: r[:guid]).count
|
|
unplayed = $db_p[:episodes].where(show_guid: r[:guid], played: 0).count
|
|
printf("%-40s %-60s arch=%d eps=%d unplayed=%d\n",
|
|
r[:slug][0,40], r[:title][0,60], r[:archive], ep_count, unplayed)
|
|
end
|
|
else
|
|
rows.each do |r|
|
|
printf("%-40s %-60s arch=%d\n", r[:slug][0,40], r[:title][0,60], r[:archive])
|
|
end
|
|
end
|
|
end
|
|
|
|
def cmd_fetch_all
|
|
shows = $db_s[:shows].where(archive: 1).all
|
|
if shows.empty?
|
|
log("INFO", "No archived shows to fetch.")
|
|
return
|
|
end
|
|
shows.each do |show|
|
|
log("INFO", "Fetching: #{show[:title]}")
|
|
tmp_feed = "/tmp/radio_fetch_#{show[:slug]}.xml"
|
|
unless http_stream_to_file(show[:feed_url], tmp_feed)
|
|
log("WARN", "Feed download failed for #{show[:slug]}, skipping")
|
|
next
|
|
end
|
|
|
|
parsed = parse_feed({:file_path => tmp_feed, :feed_url => show[:feed_url]})
|
|
begin
|
|
File.delete(tmp_feed)
|
|
rescue StandardError
|
|
# ignore cleanup errors
|
|
end
|
|
|
|
next unless parsed
|
|
|
|
show_dir = File.join(PODCAST_DIR, show[:slug])
|
|
ensure_dir(show_dir)
|
|
|
|
new_eps = 0
|
|
parsed[:items].each do |ep|
|
|
existing = $db_p[:episodes].where(guid: ep[:guid]).first
|
|
if existing
|
|
next
|
|
end
|
|
|
|
filename = "#{ep[:guid]}.mp3"
|
|
dest = File.join(show_dir, filename)
|
|
dl_ok = http_stream_to_file(ep[:url], dest)
|
|
size = dl_ok ? File.size(dest) : 0
|
|
|
|
now = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
|
|
$db_p[:episodes].insert(
|
|
guid: ep[:guid],
|
|
show_guid: show[:guid],
|
|
title: ep[:title],
|
|
url: ep[:url],
|
|
duration_seconds: ep[:duration_seconds],
|
|
played: 0,
|
|
local_path: dest,
|
|
file_size_bytes: size,
|
|
created_at: now
|
|
)
|
|
new_eps += 1
|
|
end
|
|
log("INFO", "#{show[:slug]}: #{new_eps} new episodes downloaded")
|
|
end
|
|
end
|
|
|
|
sync_gpodder
|
|
|
|
command = ARGV.shift
|
|
case command
|
|
when "--list"
|
|
detail = ARGV.include?("--detail")
|
|
cmd_list(detail)
|
|
when "--add-show"
|
|
cmd_add_show(ARGV)
|
|
when "--remove"
|
|
cmd_remove_show(ARGV.first)
|
|
when "--archive"
|
|
cmd_archive(ARGV.first, true)
|
|
when "--unarchive"
|
|
cmd_archive(ARGV.first, false)
|
|
when "--import-opml"
|
|
cmd_import_opml(ARGV.first)
|
|
when "--fetch-all"
|
|
cmd_fetch_all
|
|
else
|
|
puts "Usage: fetch_podcasts.rb [--list|--detail|--add-show <url>|--remove <slug>|--archive <slug>|--unarchive <slug>|--import-opml <file>|--fetch-all]"
|
|
end
|