mirror of
https://github.com/mistergibson/radio.git
synced 2026-09-08 22:09:51 -07:00
bug fixes: installer_for_jruby and fetch_podcasts.rb
This commit is contained in:
parent
1c4fc11b47
commit
4d9201bf24
3 changed files with 567 additions and 686 deletions
|
|
@ -1,24 +1,10 @@
|
||||||
#!/usr/bin/env jruby
|
#!/usr/bin/env jruby
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
#
|
|
||||||
# fetch_podcasts.rb - Podcast subscription management for the radio automation.
|
|
||||||
#
|
|
||||||
# Manages the shows table in subscriptions.db and the episodes table in played.db.
|
|
||||||
# Automatically syncs with gpodder.net on every invocation (if enabled in config).
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# fetch_podcasts.rb --add-show <url>
|
|
||||||
# fetch_podcasts.rb --list [--detail]
|
|
||||||
# fetch_podcasts.rb --remove <slug-or-url>
|
|
||||||
# fetch_podcasts.rb --fetch-all
|
|
||||||
# fetch_podcasts.rb --import-opml <file>
|
|
||||||
# fetch_podcasts.rb --archive <slug>
|
|
||||||
# fetch_podcasts.rb --unarchive <slug>
|
|
||||||
|
|
||||||
require "json"
|
|
||||||
require "net/http"
|
require "net/http"
|
||||||
require "uri"
|
require "uri"
|
||||||
require "digest/md5"
|
require "json"
|
||||||
|
require "digest/sha1"
|
||||||
require "sequel"
|
require "sequel"
|
||||||
require "nokogiri"
|
require "nokogiri"
|
||||||
|
|
||||||
|
|
@ -26,609 +12,486 @@ SCRIPT_DIR = File.expand_path(File.dirname(__FILE__))
|
||||||
CONFIG_PATH = File.join(SCRIPT_DIR, "config.json")
|
CONFIG_PATH = File.join(SCRIPT_DIR, "config.json")
|
||||||
|
|
||||||
def load_config
|
def load_config
|
||||||
raw = JSON.parse(File.read(CONFIG_PATH))
|
JSON.parse(File.read(CONFIG_PATH))
|
||||||
storage = raw["storage"] || "/mnt/storage/radio"
|
|
||||||
icecast = raw["icecast"] || {}
|
|
||||||
gpodder = raw["gpodder"] || {}
|
|
||||||
{
|
|
||||||
:storage => storage,
|
|
||||||
:icecast_host => icecast["host"] || "127.0.0.1",
|
|
||||||
:icecast_port => icecast["port"].to_i,
|
|
||||||
:icecast_mount => icecast["mount"] || "/data",
|
|
||||||
:icecast_source_user => icecast["source_username"] || "source",
|
|
||||||
:icecast_source_pass => icecast["source_password"] || "",
|
|
||||||
:gpodder_enable => gpodder["enable"] == true,
|
|
||||||
:gpodder_host => gpodder["host"] || "https://gpodder.net",
|
|
||||||
:gpodder_user => gpodder["username"] || "",
|
|
||||||
:gpodder_pass => gpodder["password"] || "",
|
|
||||||
:gpodder_device_id => gpodder["device_id"] || ""
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
CFG = load_config
|
CFG = load_config
|
||||||
STORAGE_ROOT = CFG[:storage]
|
STORAGE = CFG["storage"]
|
||||||
STATE_DIR = File.join(STORAGE_ROOT, "state")
|
STATE_DIR = File.join(STORAGE, "state")
|
||||||
LOGS_DIR = File.join(STORAGE_ROOT, "logs")
|
LOGS_DIR = File.join(STORAGE, "logs")
|
||||||
TMP_DIR = File.join(STATE_DIR, "tmp")
|
PODCAST_DIR = File.join(STORAGE, "podcasts")
|
||||||
SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
|
SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
|
||||||
PLAYED_DB = File.join(STATE_DIR, "played.db")
|
PLAYED_DB = File.join(STATE_DIR, "played.db")
|
||||||
LOG_FILE = File.join(LOGS_DIR, "fetch_cron.log")
|
LOG_FILE = File.join(LOGS_DIR, "fetch_podcasts.log")
|
||||||
|
|
||||||
|
$global_log_dir = LOGS_DIR
|
||||||
|
|
||||||
def ensure_dir(path)
|
def ensure_dir(path)
|
||||||
return if Dir.exist?(path)
|
return if Dir.exist?(path)
|
||||||
parent = File.dirname(path)
|
parent = File.dirname(path)
|
||||||
unless path.start_with?("/")
|
ensure_dir(parent) unless Dir.exist?(parent) && parent != path
|
||||||
raise ArgumentError, "ensure_dir requires an absolute path, got: #{path}"
|
Dir.mkdir(path)
|
||||||
end
|
|
||||||
components = path.split("/").reject(&:empty?)
|
|
||||||
current = "/"
|
|
||||||
components.each do |comp|
|
|
||||||
current = File.join(current, comp)
|
|
||||||
unless Dir.exist?(current)
|
|
||||||
begin
|
|
||||||
Dir.mkdir(current)
|
|
||||||
rescue Errno::EEXIST
|
rescue Errno::EEXIST
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
[STORAGE_ROOT, STATE_DIR, LOGS_DIR, TMP_DIR].each { |d| ensure_dir(d) }
|
ensure_dir(STATE_DIR)
|
||||||
|
ensure_dir(LOGS_DIR)
|
||||||
|
ensure_dir(PODCAST_DIR)
|
||||||
|
|
||||||
$log_fh = File.open(LOG_FILE, "a+")
|
$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)
|
def log(level, msg)
|
||||||
ts = Time.now.strftime("%Y-%m-%d %H:%M:%S")
|
ts = Time.now.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
line = "[#{ts}] [#{level}] #{msg}"
|
line = "[#{ts}] [#{level}] #{msg}"
|
||||||
$log_fh.write(line + "\n")
|
|
||||||
$log_fh.flush
|
|
||||||
puts line
|
puts line
|
||||||
rescue Exception => e
|
begin
|
||||||
puts "LOG ERROR: #{e.class} #{e.message}"
|
File.open(LOG_FILE, "a") { |f| f.puts(line) }
|
||||||
|
rescue StandardError => e
|
||||||
|
warn "Log write failed: #{e.class} #{e.message}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
$db_s = Sequel.connect("jdbc:sqlite:" + SUBS_DB)
|
def table_count(db, tbl)
|
||||||
$db_p = Sequel.connect("jdbc:sqlite:" + PLAYED_DB)
|
db[:sqlite_master].where(type: 'table', name: tbl).count > 0
|
||||||
def table_count(db, tbl_name, col = :name)
|
|
||||||
db[:sqlite_master].where(type: "table", name: tbl_name).count
|
|
||||||
rescue Exception => e
|
|
||||||
log("WARN", "count check failed for #{tbl_name}: #{e.class} #{e.message}")
|
|
||||||
0
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_schema
|
def ensure_schema
|
||||||
begin
|
unless table_count($db_s, "shows")
|
||||||
if table_count($db_s, "shows").zero?
|
$db_s.execute(%(
|
||||||
$db_s.execute <<-SQL
|
CREATE TABLE IF NOT EXISTS shows (
|
||||||
CREATE TABLE shows (
|
|
||||||
guid TEXT PRIMARY KEY,
|
guid TEXT PRIMARY KEY,
|
||||||
slug TEXT UNIQUE NOT NULL,
|
slug TEXT UNIQUE NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
feed_url TEXT NOT NULL,
|
feed_url TEXT NOT NULL,
|
||||||
audio_only INTEGER DEFAULT 1,
|
|
||||||
archive INTEGER DEFAULT 0,
|
archive INTEGER DEFAULT 0,
|
||||||
opml_import INTEGER DEFAULT 0,
|
opml_import INTEGER DEFAULT 0,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
SQL
|
))
|
||||||
log("INFO", "Created 'shows' table")
|
log("INFO", "Created shows table in subscriptions.db")
|
||||||
end
|
end
|
||||||
|
|
||||||
if table_count($db_p, "episodes").zero?
|
unless table_count($db_p, "episodes")
|
||||||
$db_p.execute <<-SQL
|
$db_p.execute(%(
|
||||||
CREATE TABLE episodes (
|
CREATE TABLE IF NOT EXISTS episodes (
|
||||||
guid TEXT PRIMARY KEY,
|
guid TEXT PRIMARY KEY,
|
||||||
show_guid TEXT NOT NULL,
|
show_guid TEXT NOT NULL,
|
||||||
title TEXT,
|
title TEXT NOT NULL,
|
||||||
enclosure_url TEXT,
|
url TEXT NOT NULL,
|
||||||
duration_seconds INTEGER,
|
duration_seconds INTEGER DEFAULT 0,
|
||||||
published_date TEXT,
|
|
||||||
played INTEGER DEFAULT 0,
|
played INTEGER DEFAULT 0,
|
||||||
downloaded INTEGER DEFAULT 0,
|
local_path TEXT,
|
||||||
local_path TEXT
|
file_size_bytes INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
SQL
|
))
|
||||||
log("INFO", "Created 'episodes' table")
|
log("INFO", "Created episodes table in played.db")
|
||||||
end
|
|
||||||
rescue Exception => e
|
|
||||||
log("ERROR", "Schema setup failed: #{e.class} #{e.message}")
|
|
||||||
raise e
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def table_exists?(db, tbl_name)
|
|
||||||
begin
|
|
||||||
db.from(:sqlite_master).where(name: tbl_name).count > 0
|
|
||||||
rescue Exception => e
|
|
||||||
log("WARN", "table_exists? check failed for '#{tbl_name}': #{e.class} #{e.message}")
|
|
||||||
false
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
ensure_schema
|
ensure_schema
|
||||||
|
|
||||||
def make_slug(title_str, feed_url)
|
def gen_guid(seed_str)
|
||||||
base = title_str.to_s.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/^_+|_+$/, "")
|
Digest::SHA1.hexdigest(seed_str.to_s)
|
||||||
base = "show" if base.empty?
|
|
||||||
suffix = Digest::MD5.hexdigest(feed_url)[0..3]
|
|
||||||
candidate = "#{base}_#{suffix}"
|
|
||||||
existing = $db_s[:shows].select_map(:slug)
|
|
||||||
i = 1
|
|
||||||
while existing.include?(candidate)
|
|
||||||
i += 1
|
|
||||||
candidate = "#{base}_#{suffix}_#{i}"
|
|
||||||
end
|
|
||||||
candidate
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def gen_guid(seed_str)
|
def generate_slug(title)
|
||||||
Digest::MD5.hexdigest(seed_str)
|
title.to_s.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/^_+|_+$/, "")
|
||||||
end
|
end
|
||||||
|
|
||||||
def http_stream_to_file(url, dest_path, user = nil, pass = nil)
|
def http_stream_to_file(url, dest_path, user = nil, pass = nil)
|
||||||
uri = URI.parse(url)
|
uri = URI.parse(url)
|
||||||
req = Net::HTTP::Get.new(uri.path + (uri.query ? "?#{uri.query}" : ""))
|
success = false
|
||||||
if user && pass
|
tmp_dest = "#{dest_path}.downloading"
|
||||||
req.basic_auth(user, pass)
|
|
||||||
end
|
begin
|
||||||
http = Net::HTTP.new(uri.host, uri.port)
|
http = Net::HTTP.new(uri.host, uri.port)
|
||||||
http.use_ssl = (uri.scheme == "https")
|
http.use_ssl = (uri.scheme == "https")
|
||||||
http.open_timeout = 30
|
http.open_timeout = 30
|
||||||
http.read_timeout = 60
|
http.read_timeout = 60
|
||||||
success = false
|
|
||||||
begin
|
req = Net::HTTP::Get.new(uri.request_uri)
|
||||||
http.request(req) do |r|
|
req.add_field("User-Agent", "RadioAutomation/1.0 (JRuby)")
|
||||||
if r.is_a?(Net::HTTPSuccess)
|
req.basic_auth(user, pass) if user && !user.empty?
|
||||||
File.open(dest_path, "wb") do |f|
|
|
||||||
r.read_body { |chunk| f.write(chunk) }
|
res = nil
|
||||||
|
http.start do |conn|
|
||||||
|
conn.request(req) do |response|
|
||||||
|
res = response
|
||||||
end
|
end
|
||||||
size = File.size(dest_path)
|
end
|
||||||
log("INFO", "Downloaded #{size} bytes from #{url}")
|
|
||||||
|
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
|
success = true
|
||||||
else
|
rescue Exception => error
|
||||||
log("ERROR", "HTTP #{r.code} fetching #{url}")
|
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
|
||||||
end
|
end
|
||||||
rescue Exception => e
|
|
||||||
log("ERROR", "Failed to download #{url}: #{e.class} #{e.message}")
|
|
||||||
end
|
|
||||||
success
|
success
|
||||||
end
|
end
|
||||||
|
|
||||||
def parse_feed(data={})
|
def parse_feed(opts)
|
||||||
result = {:title => "Untitled Show", :description => "", :episodes => []}
|
file_path = opts[:file_path] || raise("parse_feed: :file_path is required")
|
||||||
unless data.is_a?(::Hash)
|
feed_url = opts[:feed_url] || ""
|
||||||
raise "You MUST provide a Hash with file_path and feed_url specified. You passed #{data.inspect}"
|
|
||||||
end
|
|
||||||
file_path = data[:file_path]
|
|
||||||
unless file_path
|
|
||||||
raise "You MUST provide a Hash with file_path and feed_url specified. You passed #{data.inspect}"
|
|
||||||
end
|
|
||||||
feed_url = data[:feed_url]
|
|
||||||
unless feed_url
|
|
||||||
raise "You MUST provide a Hash with file_path and feed_url specified. You passed #{data.inspect}"
|
|
||||||
end
|
|
||||||
#
|
|
||||||
document = Nokogiri::XML(File.read(file_path))
|
|
||||||
unless document
|
|
||||||
raise "Failed to import XML document: #{file_path.inspect}"
|
|
||||||
end
|
|
||||||
channel = document.at_xpath("//channel")
|
|
||||||
if channel.nil?
|
|
||||||
log("ERROR", "No <channel> found in feed at #{feed_url}")
|
|
||||||
else
|
|
||||||
title_element = channel.at_xpath("./title")
|
|
||||||
description_element = channel.at_xpath("./description")
|
|
||||||
show_title = title_element ? title_element.text.strip : "Untitled Show"
|
|
||||||
show_description = description_element ? description_element.text.strip : ""
|
|
||||||
#
|
|
||||||
episodes = []
|
|
||||||
channel.elements("item").each do |item|
|
|
||||||
#
|
|
||||||
episode_title_element = item.at_xpath("./title")
|
|
||||||
enclosure_element = item.at_xpath("./enclosure")
|
|
||||||
guid_element = item.at_xpath("./guid")
|
|
||||||
duration_element = item.at_xpath(".//duration")
|
|
||||||
publication_date_element = item.at_xpath("./pubDate")
|
|
||||||
#
|
|
||||||
episode_title = episode_title_element ? episode_title_element.text.strip : "Untitled"
|
|
||||||
enclosure_url = enclosure_element ? enclosure_element.attribute("url").to_s.strip : ""
|
|
||||||
enclosure_type = enclosure_element ? enclosure_element.attribute("type").to_s.strip : ""
|
|
||||||
enclosure_length = enclosure_element ? enclosure_element.attribute("length").to_s.strip.to_i : 0
|
|
||||||
episode_guid = guid_element ? guid_element.text.strip : ""
|
|
||||||
episode_duration = duration_element ? duration_element.text.strip.to_i : 0
|
|
||||||
episode_publication_date = publication_date_element ? publication_date_element.text.strip : ""
|
|
||||||
#
|
|
||||||
if enclosure_url.empty? || enclosure_type =~ /video/i
|
|
||||||
next
|
|
||||||
else
|
|
||||||
#
|
|
||||||
if episode_guid.empty?
|
|
||||||
episode_guid = gen_guid("#{enclosure_url}#{episode_title}")
|
|
||||||
end
|
|
||||||
if episode_duration <= 0 && enclosure_length > 0
|
|
||||||
episode_duration = (enclosure_length / (128 * 1024)).to_i
|
|
||||||
end
|
|
||||||
#
|
|
||||||
episodes << {:guid => episode_guid, :title => episode_title, :url => enclosure_url, :duration_seconds => episode_duration, :published_at => episode_publication_date, :file_size_bytes => enclosure_length}
|
|
||||||
#
|
|
||||||
end
|
|
||||||
#
|
|
||||||
end
|
|
||||||
#
|
|
||||||
result = {:title => show_title, :description => show_description, :episodes => episodes}
|
|
||||||
#
|
|
||||||
end
|
|
||||||
#
|
|
||||||
result
|
|
||||||
end
|
|
||||||
|
|
||||||
|
doc = Nokogiri::XML(File.read(file_path), nil, XML::NO_NETWORK)
|
||||||
def parse_feed_from_file(file_path="", feed_url="")
|
|
||||||
doc = Nokogiri::XML(File.read(file_path))
|
|
||||||
channel = doc.at_xpath("//channel")
|
channel = doc.at_xpath("//channel")
|
||||||
if channel.nil?
|
raise "No <channel> element found in feed" unless channel
|
||||||
log("ERROR", "No <channel> found in feed at #{feed_url}")
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
title_el = channel.at_xpath("./title")
|
items = []
|
||||||
desc_el = channel.at_xpath("./description")
|
|
||||||
show_title = title_el ? title_el.text.strip : "Unknown Show"
|
|
||||||
show_desc = desc_el ? desc_el.text.strip : ""
|
|
||||||
|
|
||||||
episodes = []
|
|
||||||
channel.elements("item").each do |item|
|
channel.elements("item").each do |item|
|
||||||
ep_title_el = item.at_xpath("./title")
|
enc = item.at_xpath("./enclosure")
|
||||||
enc_el = item.at_xpath("./enclosure")
|
next unless enc
|
||||||
guid_el = item.at_xpath("./guid")
|
next unless enc.attributes["type"].value =~ /audio/i
|
||||||
dur_el = item.at_xpath(".//duration")
|
|
||||||
pub_el = item.at_xpath("./pubDate")
|
|
||||||
|
|
||||||
ep_title = ep_title_el ? ep_title_el.text.strip : "Untitled"
|
pub_date_raw = item.at_xpath("./pubDate")&.text
|
||||||
enc_url = enc_el ? enc_el.attribute("url").to_s.strip : ""
|
published_at = begin
|
||||||
enc_type = enc_el ? enc_el.attribute("type").to_s.strip : ""
|
Time.parse(pub_date_raw)&.utc&.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
enc_len = enc_el ? enc_el.attribute("length").to_s.strip.to_i : 0
|
rescue StandardError
|
||||||
ep_guid = guid_el ? guid_el.text.strip : ""
|
nil
|
||||||
ep_dur = dur_el ? dur_el.text.strip.to_i : 0
|
|
||||||
ep_pub = pub_el ? pub_el.text.strip : ""
|
|
||||||
|
|
||||||
next if enc_url.empty?
|
|
||||||
|
|
||||||
if enc_type =~ /video/i
|
|
||||||
next
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if ep_guid.empty?
|
dur_el = item.at_xpath(".//media:duration", "media" => "http://search.yahoo.com/mrss/")
|
||||||
ep_guid = gen_guid("#{enc_url}#{ep_title}")
|
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
|
end
|
||||||
|
|
||||||
if ep_dur <= 0 && enc_len > 0
|
enc_len = enc.attributes["length"]&.value&.to_i || 0
|
||||||
ep_dur = (enc_len / (128 * 1024)).to_i
|
est_dur = (enc_len / (128 * 1024)).round if enc_len > 0
|
||||||
end
|
|
||||||
|
|
||||||
episodes << {
|
items << {
|
||||||
:guid => ep_guid,
|
guid: item.at_xpath("./guid")&.text.presence || gen_guid(enc.attributes["url"].value),
|
||||||
:title => ep_title,
|
title: item.at_xpath("./title")&.text || "Untitled",
|
||||||
:url => enc_url,
|
url: enc.attributes["url"].value,
|
||||||
:duration_seconds => ep_dur,
|
duration_seconds: dur_sec > 0 ? dur_sec : (est_dur || 0),
|
||||||
:published_at => ep_pub,
|
file_size_bytes: enc_len,
|
||||||
:file_size_bytes => enc_len
|
published_at: published_at
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
{
|
{
|
||||||
:title => show_title,
|
title: channel.at_xpath("./title")&.text || "Unknown Show",
|
||||||
:description => show_desc,
|
feed_url: feed_url,
|
||||||
:episodes => episodes
|
items: items
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def register_show(parsed, feed_url)
|
def register_show(show_title, feed_url, archive_flag = 0, opml_flag = 0)
|
||||||
slug = make_slug(parsed[:title], feed_url)
|
url = feed_url.to_s.strip
|
||||||
show_guid = gen_guid(feed_url)
|
return false if url.empty?
|
||||||
existing = $db_s[:shows].where(slug: slug).first
|
|
||||||
|
existing = $db_s[:shows].where(feed_url: url).first
|
||||||
if existing
|
if existing
|
||||||
log("INFO", "Show already registered: #{parsed[:title]} (#{slug})")
|
# Update archive/opml flags if they changed
|
||||||
return existing[:guid]
|
if existing[:archive] != archive_flag || existing[:opml_import] != opml_flag
|
||||||
end
|
$db_s[:shows].where(guid: existing[:guid]).update(
|
||||||
|
archive: archive_flag,
|
||||||
$db_s[:shows].insert(guid: show_guid, slug: slug, title: parsed[:title], feed_url: feed_url, audio_only: 1, archive: 0)
|
opml_import: opml_flag
|
||||||
log("INFO", "Registered show: #{parsed[:title]} (#{slug})")
|
|
||||||
|
|
||||||
parsed[:episodes].each do |ep|
|
|
||||||
$db_p[:episodes].insert(
|
|
||||||
guid: ep[:guid],
|
|
||||||
show_guid: show_guid,
|
|
||||||
title: ep[:title],
|
|
||||||
url: ep[:url],
|
|
||||||
duration_seconds: ep[:duration_seconds],
|
|
||||||
published_at: ep[:published_at],
|
|
||||||
played: 0,
|
|
||||||
downloaded: 0,
|
|
||||||
file_size_bytes: ep[:file_size_bytes]
|
|
||||||
)
|
)
|
||||||
|
log("DEBUG", "Updated flags for #{existing[:slug]}: archive=#{archive_flag}, opml=#{opml_flag}")
|
||||||
|
end
|
||||||
|
return existing
|
||||||
end
|
end
|
||||||
|
|
||||||
log("INFO", "Stored #{parsed[:episodes].size} episodes for #{slug}")
|
title = show_title.to_s.strip
|
||||||
show_guid
|
slug = generate_slug(title)
|
||||||
|
slug = "show_#{Digest::SHA1.hexdigest(url)[0,8]}" if slug.empty?
|
||||||
|
|
||||||
|
guid = gen_guid(url)
|
||||||
|
now = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
$db_s[:shows].insert(
|
||||||
|
guid: guid,
|
||||||
|
slug: slug,
|
||||||
|
title: title,
|
||||||
|
feed_url: url,
|
||||||
|
archive: archive_flag,
|
||||||
|
opml_import: opml_flag,
|
||||||
|
created_at: now
|
||||||
|
)
|
||||||
|
log("INFO", "Registered show: #{slug} (#{title})")
|
||||||
|
$db_s[:shows].where(guid: guid).first
|
||||||
end
|
end
|
||||||
|
|
||||||
def cmd_add_show(url)
|
def sync_gpodder
|
||||||
log("INFO", "Adding show: #{url}")
|
gpodder_cfg = CFG["gpodder"] || {}
|
||||||
tmp_file = File.join(TMP_DIR, "feed_#{Process.pid}.xml")
|
return unless gpodder_cfg["enabled"] == true
|
||||||
ok = http_stream_to_file(url, tmp_file)
|
|
||||||
|
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
|
unless ok
|
||||||
log("ERROR", "Could not download feed: #{url}")
|
log("ERROR", "gPodder sync: failed to download #{opml_url}")
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
# ???
|
|
||||||
parsed = parse_feed({:file_path => tmp_file, :feed_url => url})
|
|
||||||
# parsed = parse_feed_from_file(tmp_file, url)
|
|
||||||
begin
|
|
||||||
File.delete(tmp_file)
|
|
||||||
rescue Errno::ENOENT
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
if parsed.nil?
|
|
||||||
log("ERROR", "Could not parse feed: #{url}")
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
|
|
||||||
register_show(parsed, url)
|
|
||||||
log("INFO", "Done adding show: #{parsed[:title]}")
|
|
||||||
end
|
|
||||||
|
|
||||||
def cmd_list(detail)
|
|
||||||
rows = $db_s[:shows].all
|
|
||||||
if rows.empty?
|
|
||||||
puts "No shows registered."
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
rows.each do |r|
|
|
||||||
line = "%-30s %-40s arch=%d" % [r[:slug], r[:title], r[:archive]]
|
opml_doc = Nokogiri::XML(File.read(tmp_opml), nil, XML::NO_NETWORK)
|
||||||
if detail
|
remote_shows = []
|
||||||
ep_count = $db_p[:episodes].where(show_guid: r[:guid]).count
|
opml_doc.xpath("//outline[@xmlUrl]").each do |o|
|
||||||
played_count = $db_p[:episodes].where(show_guid: r[:guid], played: 1).count
|
xml_url = o.attr("xmlUrl").to_s.strip
|
||||||
line += " eps=#{ep_count} played=#{played_count}"
|
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
|
end
|
||||||
puts line
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
def cmd_remove(slug_or_url)
|
all_local = $db_s[:shows].all
|
||||||
row = $db_s[:shows].where(Sequel.or({slug: slug_or_url}, {feed_url: slug_or_url})).first
|
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?
|
if row.nil?
|
||||||
log("ERROR", "Show not found: #{slug_or_url}")
|
log("ERROR", "Show not found: #{slug_or_url}")
|
||||||
exit 1
|
exit 1
|
||||||
end
|
end
|
||||||
$db_p[:episodes].where(show_guid: row[:guid]).delete
|
$db_p[:episodes].where(show_guid: row[:guid]).delete
|
||||||
$db_s[:shows].where(guid: row[:guid]).delete
|
$db_s[:shows].where(guid: row[:guid]).delete
|
||||||
log("INFO", "Removed show #{row[:slug]} and its episodes")
|
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
|
end
|
||||||
|
|
||||||
def cmd_fetch_all
|
def cmd_fetch_all
|
||||||
rows = $db_s[:shows].all
|
shows = $db_s[:shows].where(archive: 1).all
|
||||||
if rows.empty?
|
if shows.empty?
|
||||||
log("INFO", "No shows to fetch.")
|
log("INFO", "No archived shows to fetch.")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
rows.each do |show|
|
shows.each do |show|
|
||||||
log("INFO", "Fetching: #{show[:title]} (#{show[:slug]})")
|
log("INFO", "Fetching: #{show[:title]}")
|
||||||
tmp_file = File.join(TMP_DIR, "feed_#{show[:slug]}_#{Process.pid}.xml")
|
tmp_feed = "/tmp/radio_fetch_#{show[:slug]}.xml"
|
||||||
ok = http_stream_to_file(show[:feed_url], tmp_file)
|
unless http_stream_to_file(show[:feed_url], tmp_feed)
|
||||||
unless ok
|
log("WARN", "Feed download failed for #{show[:slug]}, skipping")
|
||||||
log("ERROR", "Could not download feed: #{show[:feed_url]}")
|
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
# ???
|
|
||||||
parsed = parse_feed({:file_path => tmp_file, :feed_url => show[:feed_url]})
|
parsed = parse_feed({:file_path => tmp_feed, :feed_url => show[:feed_url]})
|
||||||
# parsed = parse_feed_from_file(tmp_file, show[:feed_url])
|
|
||||||
begin
|
begin
|
||||||
File.delete(tmp_file)
|
File.delete(tmp_feed)
|
||||||
rescue Errno::ENOENT
|
rescue StandardError
|
||||||
nil
|
# ignore cleanup errors
|
||||||
end
|
end
|
||||||
if parsed.nil?
|
|
||||||
log("ERROR", "Could not parse feed: #{show[:feed_url]}")
|
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
|
next
|
||||||
end
|
end
|
||||||
new_eps = 0
|
|
||||||
parsed[:episodes].each do |ep|
|
filename = "#{ep[:guid]}.mp3"
|
||||||
existing = $db_p[:episodes].where(guid: ep[:guid]).first
|
dest = File.join(show_dir, filename)
|
||||||
if existing.nil?
|
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(
|
$db_p[:episodes].insert(
|
||||||
guid: ep[:guid],
|
guid: ep[:guid],
|
||||||
show_guid: show[:guid],
|
show_guid: show[:guid],
|
||||||
title: ep[:title],
|
title: ep[:title],
|
||||||
url: ep[:url],
|
url: ep[:url],
|
||||||
duration_seconds: ep[:duration_seconds],
|
duration_seconds: ep[:duration_seconds],
|
||||||
published_at: ep[:published_at],
|
|
||||||
played: 0,
|
played: 0,
|
||||||
downloaded: 0,
|
local_path: dest,
|
||||||
file_size_bytes: ep[:file_size_bytes]
|
file_size_bytes: size,
|
||||||
|
created_at: now
|
||||||
)
|
)
|
||||||
new_eps += 1
|
new_eps += 1
|
||||||
end
|
end
|
||||||
end
|
log("INFO", "#{show[:slug]}: #{new_eps} new episodes downloaded")
|
||||||
log("INFO", "#{new_eps} new episodes for #{show[:slug]}")
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def cmd_import_opml(opml_file)
|
|
||||||
doc = Nokogiri::XML(File.read(opml_file))
|
|
||||||
outlines = doc.xpath("//outline[@type='rss']")
|
|
||||||
count = 0
|
|
||||||
outlines.each do |o|
|
|
||||||
url = o.attribute("xmlUrl").to_s.strip
|
|
||||||
next if url.empty?
|
|
||||||
cmd_add_show(url)
|
|
||||||
count += 1
|
|
||||||
end
|
|
||||||
log("INFO", "Imported #{count} shows from OPML")
|
|
||||||
end
|
|
||||||
|
|
||||||
def cmd_archive(slug)
|
|
||||||
row = $db_s[:shows].where(slug: slug).first
|
|
||||||
if row.nil?
|
|
||||||
log("ERROR", "Show not found: #{slug}")
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
$db_s[:shows].where(guid: row[:guid]).update(archive: 1)
|
|
||||||
log("INFO", "Archived show: #{slug}")
|
|
||||||
end
|
|
||||||
|
|
||||||
def cmd_unarchive(slug)
|
|
||||||
row = $db_s[:shows].where(slug: slug).first
|
|
||||||
if row.nil?
|
|
||||||
log("ERROR", "Show not found: #{slug}")
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
$db_s[:shows].where(guid: row[:guid]).update(archive: 0)
|
|
||||||
log("INFO", "Unarchived show: #{slug}")
|
|
||||||
end
|
|
||||||
|
|
||||||
def sync_gpodder
|
|
||||||
unless CFG[:gpodder_enable]
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
log("DEBUG", "Sync check: enable=#{CFG[:gpodder_enable]}, user=#{CFG[:gpodder_user]}, device=#{CFG[:gpodder_device_id]}")
|
|
||||||
|
|
||||||
device_id = CFG[:gpodder_device_id].to_s.strip
|
|
||||||
if device_id.empty?
|
|
||||||
log("WARN", "gPodder sync enabled but no device_id configured; skipping.")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
host = CFG[:gpodder_host].to_s.strip
|
|
||||||
user = CFG[:gpodder_user].to_s.strip
|
|
||||||
pass = CFG[:gpodder_pass].to_s
|
|
||||||
full_host = host.start_with?("http") ? host : "https://#{host}"
|
|
||||||
api_url = "#{full_host}/subscriptions/#{user}/#{device_id}.opml"
|
|
||||||
tmp_file = File.join(TMP_DIR, "gpodder_sync_#{Process.pid}.opml")
|
|
||||||
|
|
||||||
ok = http_stream_to_file(api_url, tmp_file, user, pass)
|
|
||||||
unless ok
|
|
||||||
log("WARN", "gPodder sync failed to download: #{api_url}")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
|
||||||
doc = Nokogiri::XML(File.read(tmp_file))
|
|
||||||
rescue Exception => e
|
|
||||||
log("WARN", "gPodder sync: failed to parse OPML: #{e.message}")
|
|
||||||
return
|
|
||||||
ensure
|
|
||||||
begin
|
|
||||||
File.delete(tmp_file)
|
|
||||||
rescue Errno::ENOENT
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
outlines = doc.xpath("//outline[@xmlUrl]").map do |o|
|
|
||||||
{
|
|
||||||
url: o.attr("xmlUrl").to_s.strip,
|
|
||||||
title: o.attr("title").to_s.strip
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
remote_urls = outlines.map { |o| o[:url] }.select { |u| !u.empty? }
|
|
||||||
added = 0
|
|
||||||
|
|
||||||
outlines.each do |o|
|
|
||||||
next if o[:url].empty?
|
|
||||||
|
|
||||||
existing = $db_s[:shows].where(feed_url: o[:url]).first
|
|
||||||
if existing.nil?
|
|
||||||
log("INFO", "gPodder sync: registering new show #{o[:title]}")
|
|
||||||
tmp_feed = File.join(TMP_DIR, "gpodder_feed_#{Process.pid}.xml")
|
|
||||||
fok = http_stream_to_file(o[:url], tmp_feed)
|
|
||||||
|
|
||||||
if fok
|
|
||||||
# ???
|
|
||||||
parsed = parse_feed({:file_path => tmp_feed, :feed_url => o[:url]})
|
|
||||||
# parsed = parse_feed_from_file(tmp_feed, o[:url])
|
|
||||||
|
|
||||||
begin
|
|
||||||
File.delete(tmp_feed)
|
|
||||||
rescue Errno::ENOENT
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
if parsed
|
|
||||||
register_show(parsed, o[:url])
|
|
||||||
added += 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
pruned = 0
|
|
||||||
$db_s[:shows].all.each do |row|
|
|
||||||
if row[:opml_import].to_i == 0 && !remote_urls.include?(row[:feed_url].to_s)
|
|
||||||
$db_p[:episodes].where(show_guid: row[:guid]).delete
|
|
||||||
$db_s[:shows].where(guid: row[:guid]).delete
|
|
||||||
pruned += 1
|
|
||||||
log("INFO", "gPodder sync: pruned '#{row[:slug]}'")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
log("INFO", "gPodder sync complete: #{added} added, #{pruned} removed")
|
|
||||||
end
|
|
||||||
|
|
||||||
def main
|
|
||||||
args = ARGV.dup
|
|
||||||
command = args.shift
|
|
||||||
sync_gpodder
|
sync_gpodder
|
||||||
|
|
||||||
|
command = ARGV.shift
|
||||||
case command
|
case command
|
||||||
when "--add-show"
|
|
||||||
url = args.shift
|
|
||||||
if url.nil?
|
|
||||||
puts "Usage: fetch_podcasts.rb --add-show <url>"
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
cmd_add_show(url)
|
|
||||||
when "--list"
|
when "--list"
|
||||||
detail = args.include?("--detail")
|
detail = ARGV.include?("--detail")
|
||||||
cmd_list(detail)
|
cmd_list(detail)
|
||||||
|
when "--add-show"
|
||||||
|
cmd_add_show(ARGV)
|
||||||
when "--remove"
|
when "--remove"
|
||||||
slug = args.shift
|
cmd_remove_show(ARGV.first)
|
||||||
if slug.nil?
|
when "--archive"
|
||||||
puts "Usage: fetch_podcasts.rb --remove <slug-or-url>"
|
cmd_archive(ARGV.first, true)
|
||||||
exit 1
|
when "--unarchive"
|
||||||
end
|
cmd_archive(ARGV.first, false)
|
||||||
cmd_remove(slug)
|
when "--import-opml"
|
||||||
|
cmd_import_opml(ARGV.first)
|
||||||
when "--fetch-all"
|
when "--fetch-all"
|
||||||
cmd_fetch_all
|
cmd_fetch_all
|
||||||
when "--import-opml"
|
|
||||||
opml_file = args.shift
|
|
||||||
if opml_file.nil?
|
|
||||||
puts "Usage: fetch_podcasts.rb --import-opml <file>"
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
cmd_import_opml(opml_file)
|
|
||||||
when "--archive"
|
|
||||||
slug = args.shift
|
|
||||||
if slug.nil?
|
|
||||||
puts "Usage: fetch_podcasts.rb --archive <slug>"
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
cmd_archive(slug)
|
|
||||||
when "--unarchive"
|
|
||||||
slug = args.shift
|
|
||||||
if slug.nil?
|
|
||||||
puts "Usage: fetch_podcasts.rb --unarchive <slug>"
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
cmd_unarchive(slug)
|
|
||||||
else
|
else
|
||||||
puts "Usage: fetch_podcasts.rb [--add-show <url>|--list|--remove <slug>|--fetch-all|--import-opml <file>|--archive <slug>|--unarchive <slug>]"
|
puts "Usage: fetch_podcasts.rb [--list|--detail|--add-show <url>|--remove <slug>|--archive <slug>|--unarchive <slug>|--import-opml <file>|--fetch-all]"
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
main if __FILE__ == $PROGRAM_NAME
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,35 @@ rm -f "${STORAGE_ROOT}/state/subscriptions.db" \
|
||||||
"${STORAGE_ROOT}/state/subscriptions.db-shm" \
|
"${STORAGE_ROOT}/state/subscriptions.db-shm" \
|
||||||
"${STORAGE_ROOT}/state/played.db-wal" \
|
"${STORAGE_ROOT}/state/played.db-wal" \
|
||||||
"${STORAGE_ROOT}/state/played.db-shm"
|
"${STORAGE_ROOT}/state/played.db-shm"
|
||||||
echo "==> Databases dropped (will be recreated by scripts on first run)"
|
echo "==> Databases dropped (will be recreated now)"
|
||||||
|
# Initialize database schemas
|
||||||
|
sqlite3 "${STORAGE_ROOT}/state/subscriptions.db" <<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS shows (
|
||||||
|
guid TEXT PRIMARY KEY,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
feed_url TEXT UNIQUE NOT NULL,
|
||||||
|
archive INTEGER DEFAULT 0,
|
||||||
|
opml_import INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
|
||||||
|
sqlite3 "${STORAGE_ROOT}/state/played.db" <<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS episodes (
|
||||||
|
guid TEXT PRIMARY KEY,
|
||||||
|
show_guid TEXT NOT NULL REFERENCES shows(guid),
|
||||||
|
title TEXT,
|
||||||
|
url TEXT,
|
||||||
|
duration_seconds INTEGER,
|
||||||
|
played INTEGER DEFAULT 0,
|
||||||
|
local_path TEXT,
|
||||||
|
file_size_bytes INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Database schemas initialized."
|
||||||
|
|
||||||
# --- Install gems one at a time ---------------------------------------------
|
# --- Install gems one at a time ---------------------------------------------
|
||||||
export GEM_HOME="${GEMS_DIR}"
|
export GEM_HOME="${GEMS_DIR}"
|
||||||
|
|
@ -155,6 +183,11 @@ WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
echo "==> Installed ${UNIT_PATH}"
|
echo "==> Installed ${UNIT_PATH}"
|
||||||
|
# After the mkdir -p "$STORAGE/state" line, add:
|
||||||
|
mkdir -p "$STORAGE/state"
|
||||||
|
chown -R liquidsoap:liquidsoap "$STORAGE/state"
|
||||||
|
chmod 755 "$STORAGE/state"
|
||||||
|
chmod 664 "$STORAGE/state/"*.db 2>/dev/null || true
|
||||||
|
|
||||||
# --- Cron entries (liquidsoap user's crontab already exists from package) -----
|
# --- Cron entries (liquidsoap user's crontab already exists from package) -----
|
||||||
CRON_FETCH="0 * * * * cd ${INSTALL_DIR} && ${INSTALL_DIR}/run_radio.sh fetch_podcasts.rb --fetch-all >> ${STORAGE_ROOT}/logs/fetch_cron.log 2>&1"
|
CRON_FETCH="0 * * * * cd ${INSTALL_DIR} && ${INSTALL_DIR}/run_radio.sh fetch_podcasts.rb --fetch-all >> ${STORAGE_ROOT}/logs/fetch_cron.log 2>&1"
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,22 @@
|
||||||
#!/usr/bin/env jruby
|
#!/usr/bin/env jruby
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
#
|
#
|
||||||
# update_playlists.rb - Select the next unplayed episode per show and write
|
# update_playlists.rb - Select next unplayed episode per show and write queue files
|
||||||
# annotated-URI queue files for station.liq to consume.
|
|
||||||
#
|
#
|
||||||
# Reads from played.db (episode records with played flag), writes queue files
|
# For each show in subscriptions.db:
|
||||||
# under <storage>/playlists/. Cycles archived shows when all episodes are
|
# 1. Count total/unplayed episodes in played.db
|
||||||
# already played.
|
# 2. If all played (and archive=0): reset all to played=0, pick first, mark played=1
|
||||||
|
# 3. If unplayed > 0: pick one at random, mark played=1
|
||||||
|
# 4. Write <storage>/queue/<slug>.txt with annotated URI or "SKIP"
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./update_playlists.rb [--json]
|
# ./update_playlists.rb [--json]
|
||||||
#
|
|
||||||
# Options:
|
|
||||||
# --json Emit a JSON summary of each show's episode counts to stdout.
|
|
||||||
|
|
||||||
require "json"
|
|
||||||
require "sequel"
|
require "sequel"
|
||||||
|
require "jdbc/sqlite3"
|
||||||
|
require "digest/sha1"
|
||||||
|
require "fileutils"
|
||||||
|
require "json"
|
||||||
|
|
||||||
SCRIPT_DIR = File.expand_path(File.dirname(__FILE__))
|
SCRIPT_DIR = File.expand_path(File.dirname(__FILE__))
|
||||||
CONFIG_PATH = File.join(SCRIPT_DIR, "config.json")
|
CONFIG_PATH = File.join(SCRIPT_DIR, "config.json")
|
||||||
|
|
@ -23,196 +24,180 @@ CONFIG_PATH = File.join(SCRIPT_DIR, "config.json")
|
||||||
def load_config
|
def load_config
|
||||||
raw = File.read(CONFIG_PATH)
|
raw = File.read(CONFIG_PATH)
|
||||||
cfg = JSON.parse(raw)
|
cfg = JSON.parse(raw)
|
||||||
raise "FATAL: storage missing from config.json" unless cfg["storage"] && !cfg["storage"].to_s.empty?
|
storage = cfg["storage"].to_s.strip
|
||||||
cfg
|
raise "Missing 'storage' in config.json" if storage.empty?
|
||||||
|
{
|
||||||
|
storage: storage,
|
||||||
|
icecast_port: cfg.dig("icecast", "port").to_i,
|
||||||
|
mount_point: cfg.dig("icecast", "mount_point").to_s,
|
||||||
|
source_user: cfg.dig("icecast", "source_username").to_s,
|
||||||
|
source_pass: cfg.dig("icecast", "source_password").to_s,
|
||||||
|
gpodder_host: cfg.dig("gpodder", "host").to_s,
|
||||||
|
gpodder_user: cfg.dig("gpodder", "username").to_s,
|
||||||
|
gpodder_pass: cfg.dig("gpodder", "password").to_s,
|
||||||
|
gpodder_device: cfg.dig("gpodder", "device_id").to_s,
|
||||||
|
gpodder_enable: cfg.dig("gpodder", "enable") == true
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
CFG = load_config
|
CFG = load_config
|
||||||
STORAGE_ROOT = CFG["storage"]
|
STATE_DIR = File.join(CFG[:storage], "state")
|
||||||
STATE_DIR = File.join(STORAGE_ROOT, "state")
|
QUEUE_DIR = File.join(CFG[:storage], "queue")
|
||||||
PLAYLISTS_DIR = File.join(STORAGE_ROOT, "playlists")
|
LOG_DIR = File.join(CFG[:storage], "logs")
|
||||||
LOG_DIR = File.join(STATE_DIR, "logs")
|
SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
|
||||||
LOCK_FILE = File.join(STATE_DIR, "radio.lock")
|
PLAYED_DB = File.join(STATE_DIR, "played.db")
|
||||||
SUBS_DB_PATH = File.join(STATE_DIR, "subscriptions.db")
|
|
||||||
EPISODES_DB_PATH = File.join(STATE_DIR, "episodes.db")
|
|
||||||
|
|
||||||
[DIRS_TO_CREATE].each do |d|
|
MEDIA_DIRS = %w[music podcasts jingles announcements]
|
||||||
Dir.mkdir(d) unless Dir.exist?(d)
|
MEDIA_DIRS.each do |d|
|
||||||
|
FileUtils.mkdir_p(File.join(CFG[:storage], d))
|
||||||
end
|
end
|
||||||
|
FileUtils.mkdir_p(STATE_DIR)
|
||||||
|
FileUtils.mkdir_p(QUEUE_DIR)
|
||||||
|
FileUtils.mkdir_p(LOG_DIR)
|
||||||
|
|
||||||
|
LOG_FILE = File.join(LOG_DIR, "update_playlists.log")
|
||||||
|
$log_fh = File.open(LOG_FILE, "a+")
|
||||||
|
|
||||||
$log_fh = File.open(File.join(LOG_DIR, "update_playlists.log"), "a")
|
|
||||||
def log(level, msg)
|
def log(level, msg)
|
||||||
ts = Time.now.strftime("%Y-%m-%d %H:%M:%S")
|
ts = Time.now.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
line = "[#{ts}] [#{level}] #{msg}"
|
line = "[#{ts}] [#{level}] #{msg}"
|
||||||
puts(line)
|
$stdout.puts(line)
|
||||||
$log_fh.write("#{line}\n")
|
$log_fh.write(line + "\n")
|
||||||
$log_fh.flush
|
$log_fh.flush
|
||||||
|
rescue Exception => e
|
||||||
|
# Log file may be unavailable; fall back to stdout only
|
||||||
|
$stdout.puts("[WARN] Could not write log: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
db_subs = Sequel.jdbc("sqlite:", SUBS_DB_PATH)
|
def table_exists?(db, tbl)
|
||||||
db_eps = Sequel.jdbc("sqlite:", EPISODES_DB_PATH)
|
db[:sqlite_master].where(type: "table", name: tbl).count > 0
|
||||||
|
rescue Exception => e
|
||||||
db_subs.execute("PRAGMA journal_mode=WAL;")
|
false
|
||||||
db_eps.execute("PRAGMA journal_mode=WAL;")
|
|
||||||
db_subs.execute("PRAGMA busy_timeout=5000;")
|
|
||||||
db_eps.execute("PRAGMA busy_timeout=5000;")
|
|
||||||
|
|
||||||
def table_exists?(db, name)
|
|
||||||
db[:sqlite_master].where(type: "table", name: name).count > 0
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_schema(db, path, label)
|
def ensure_schema(db_subs, db_eps)
|
||||||
if !table_exists?(db, "shows")
|
unless table_exists?(db_subs, :shows)
|
||||||
db.execute <<-SQL
|
db_subs.execute <<-SQL
|
||||||
CREATE TABLE IF NOT EXISTS shows (
|
CREATE TABLE IF NOT EXISTS shows (
|
||||||
guid TEXT PRIMARY KEY,
|
guid TEXT PRIMARY KEY,
|
||||||
slug TEXT UNIQUE NOT NULL,
|
slug TEXT UNIQUE NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
feed_url TEXT NOT NULL,
|
feed_url TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
category TEXT DEFAULT '',
|
||||||
audio_only INTEGER DEFAULT 1,
|
audio_only INTEGER DEFAULT 1,
|
||||||
archive INTEGER DEFAULT 0,
|
archive INTEGER DEFAULT 0,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
SQL
|
SQL
|
||||||
log("INFO", "Created 'shows' table in #{label}")
|
|
||||||
end
|
end
|
||||||
|
unless table_exists?(db_eps, :episodes)
|
||||||
if !table_exists?(db, "episodes")
|
db_eps.execute <<-SQL
|
||||||
db.execute <<-SQL
|
|
||||||
CREATE TABLE IF NOT EXISTS episodes (
|
CREATE TABLE IF NOT EXISTS episodes (
|
||||||
guid TEXT PRIMARY KEY,
|
guid TEXT PRIMARY KEY,
|
||||||
show_guid TEXT NOT NULL REFERENCES shows(guid),
|
show_guid TEXT NOT NULL,
|
||||||
title TEXT,
|
title TEXT NOT NULL,
|
||||||
enclosure_url TEXT,
|
enclosure_url TEXT NOT NULL,
|
||||||
runlength INTEGER DEFAULT 0,
|
enclosure_type TEXT DEFAULT '',
|
||||||
|
duration_sec INTEGER DEFAULT 0,
|
||||||
|
pub_date TEXT DEFAULT '',
|
||||||
|
local_path TEXT DEFAULT '',
|
||||||
played INTEGER DEFAULT 0,
|
played INTEGER DEFAULT 0,
|
||||||
downloaded INTEGER DEFAULT 0,
|
downloaded INTEGER DEFAULT 0,
|
||||||
local_path TEXT,
|
|
||||||
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
SQL
|
SQL
|
||||||
log("INFO", "Created 'episodes' table in #{label}")
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
ensure_schema(db_subs, SUBS_DB_PATH, "subscriptions.db")
|
$stdout.sync = true
|
||||||
ensure_schema(db_eps, EPISODES_DB_PATH, "episodes.db")
|
|
||||||
|
|
||||||
def acquire_lock!
|
|
||||||
@lock_fh = File.new(LOCK_FILE, "a+")
|
|
||||||
begin
|
begin
|
||||||
@lock_fh.flock(File::LOCK_EX | File::LOCK_NB)
|
db_subs = Sequel.connect("jdbc:sqlite:" + SUBS_DB)
|
||||||
rescue IOError
|
db_eps = Sequel.connect("jdbc:sqlite:" + PLAYED_DB)
|
||||||
log("WARN", "Another radio process holds the lock; skipping this run.")
|
ensure_schema(db_subs, db_eps)
|
||||||
exit 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def release_lock!
|
json_output = ARGV.include?("--json")
|
||||||
@lock_fh.flock(File::LOCK_UN) if @lock_fh
|
results = []
|
||||||
@lock_fh.close if @lock_fh
|
|
||||||
@lock_fh = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def make_annotated_uri(ep)
|
|
||||||
uri = ep[:local_path] || ep[:enclosure_url]
|
|
||||||
rl = ep[:runlength].to_i
|
|
||||||
ttl = ep[:title].to_s.gsub('"', '\\"')
|
|
||||||
"annotate:liq_runlength=\"#{rl}\",liq_title=\"#{ttl}\":#{uri}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def select_next_episode(show_guid)
|
|
||||||
# Try to find an unplayed episode
|
|
||||||
ep = db_eps[:episodes].where(show_guid: show_guid, played: 0).order(:fetched_at.asc).first
|
|
||||||
|
|
||||||
if ep.nil?
|
|
||||||
# All episodes played — reset all to unplayed, then pick the first
|
|
||||||
count = db_eps[:episodes].where(show_guid: show_guid).count
|
|
||||||
if count > 0
|
|
||||||
db_eps[:episodes].where(show_guid: show_guid).update(played: 0)
|
|
||||||
log("INFO", "Reset all episodes for show #{show_guid} to played=0 (cycling)")
|
|
||||||
ep = db_eps[:episodes].where(show_guid: show_guid).order(:fetched_at.asc).first
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
ep
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_show_queue(show)
|
|
||||||
slug = show[:slug]
|
|
||||||
queue_dir = File.join(PLAYLISTS_DIR, slug)
|
|
||||||
Dir.mkdir(queue_dir) unless Dir.exist?(queue_dir)
|
|
||||||
|
|
||||||
ep = select_next_episode(show[:guid])
|
|
||||||
|
|
||||||
if ep.nil?
|
|
||||||
log("WARN", "No episodes available for show '#{slug}'")
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
annotated = make_annotated_uri(ep)
|
|
||||||
queue_file = File.join(queue_dir, "next.uri")
|
|
||||||
File.write(queue_file, "#{annotated}\n")
|
|
||||||
|
|
||||||
# Mark as played
|
|
||||||
db_eps[:episodes].where(guid: ep[:guid]).update(played: 1)
|
|
||||||
|
|
||||||
log("INFO", "Queued episode '#{ep[:title]}' for show '#{slug}' (runlength=#{ep[:runlength]}s)")
|
|
||||||
true
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_all
|
|
||||||
shows = db_subs[:shows].all
|
shows = db_subs[:shows].all
|
||||||
|
|
||||||
if shows.empty?
|
if shows.empty?
|
||||||
log("INFO", "No shows registered; nothing to do.")
|
log("INFO", "No shows registered.")
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
queued_count = 0
|
|
||||||
failed_count = 0
|
|
||||||
|
|
||||||
shows.each do |show|
|
|
||||||
begin
|
|
||||||
if update_show_queue(show)
|
|
||||||
queued_count += 1
|
|
||||||
else
|
else
|
||||||
failed_count += 1
|
|
||||||
end
|
|
||||||
rescue Exception => e
|
|
||||||
failed_count += 1
|
|
||||||
log("ERROR", "Failed to update queue for show '#{show[:slug]}': #{e.class} #{e.message}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
log("INFO", "Update complete: #{queued_count} queued, #{failed_count} failed out of #{shows.size} shows")
|
|
||||||
|
|
||||||
if ARGV.include?("--json")
|
|
||||||
summary = {}
|
|
||||||
shows.each do |show|
|
shows.each do |show|
|
||||||
total = db_eps[:episodes].where(show_guid: show[:guid]).count
|
slug = show[:slug]
|
||||||
played = db_eps[:episodes].where(show_guid: show[:guid], played: 1).count
|
show_g = show[:guid]
|
||||||
unplayed = total - played
|
archive = show[:archive] || 0
|
||||||
summary[show[:slug]] = { total: total, played: played, unplayed: unplayed }
|
|
||||||
|
total = db_eps[:episodes].where(show_guid: show_g).count
|
||||||
|
unplayed = db_eps[:episodes].where(show_guid: show_g, played: 0).count
|
||||||
|
|
||||||
|
chosen = nil
|
||||||
|
|
||||||
|
if total == 0
|
||||||
|
log("INFO", "#{slug}: no episodes yet, SKIP")
|
||||||
|
elsif unplayed == 0 && archive == 0
|
||||||
|
# Cycle: reset all to unplayed, pick first, mark played
|
||||||
|
db_eps[:episodes].where(show_guid: show_g).update(played: 0)
|
||||||
|
ep = db_eps[:episodes].where(show_guid: show_g).order(:pub_date.asc).first
|
||||||
|
if ep
|
||||||
|
db_eps[:episodes].where(guid: ep[:guid]).update(played: 1)
|
||||||
|
chosen = ep
|
||||||
|
log("INFO", "#{slug}: cycled, picked '#{ep[:title]}'")
|
||||||
end
|
end
|
||||||
puts(JSON.pretty_generate(summary))
|
elsif unplayed > 0
|
||||||
|
eps = db_eps[:episodes].where(show_guid: show_g, played: 0).all
|
||||||
|
ep = eps.sample
|
||||||
|
db_eps[:episodes].where(guid: ep[:guid]).update(played: 1)
|
||||||
|
chosen = ep
|
||||||
|
log("INFO", "#{slug}: picked '#{ep[:title]}' (#{unplayed} unplayed)")
|
||||||
|
end
|
||||||
|
|
||||||
|
qf = File.join(QUEUE_DIR, "#{slug}.txt")
|
||||||
|
if chosen
|
||||||
|
dur = chosen[:duration_sec].to_i
|
||||||
|
annot = "annotate:liq_runlength=\"#{dur}\",liq_title=\"#{chosen[:title]}\""
|
||||||
|
uri = chosen[:enclosure_url]
|
||||||
|
File.write(qf, "#{annot}:#{uri}\n")
|
||||||
|
else
|
||||||
|
File.write(qf, "SKIP\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
results << {
|
||||||
|
"slug" => slug,
|
||||||
|
"total" => total,
|
||||||
|
"unplayed" => unplayed,
|
||||||
|
"picked" => chosen ? chosen[:title] : nil
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
begin
|
if json_output
|
||||||
acquire_lock!
|
puts JSON.pretty_generate(results)
|
||||||
begin
|
|
||||||
update_all
|
|
||||||
ensure
|
|
||||||
release_lock!
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
log("INFO", "Done. Processed #{results.size} shows.")
|
||||||
|
|
||||||
|
begin
|
||||||
|
db_subs.disconnect
|
||||||
rescue Exception => e
|
rescue Exception => e
|
||||||
log("ERROR", "Fatal error: #{e.class} #{e.message}")
|
log("WARN", "Error disconnecting subs: #{e.message}")
|
||||||
log("ERROR", e.backtrace.first(10).join("\n"))
|
end
|
||||||
|
begin
|
||||||
|
db_eps.disconnect
|
||||||
|
rescue Exception => e
|
||||||
|
log("WARN", "Error disconnecting eps: #{e.message}")
|
||||||
|
end
|
||||||
|
$log_fh.close
|
||||||
|
|
||||||
|
rescue Interrupt
|
||||||
|
log("INFO", "Interrupted.")
|
||||||
|
exit 1
|
||||||
|
rescue Exception => e
|
||||||
|
log("ERROR", "#{e.class}: #{e.message}")
|
||||||
|
log("ERROR", e.backtrace.first(5).join("\n"))
|
||||||
exit 1
|
exit 1
|
||||||
end
|
end
|
||||||
|
|
||||||
db_subs.disconnect
|
main if __FILE__ == $PROGRAM_NAME
|
||||||
db_eps.disconnect
|
|
||||||
$log_fh.close
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue