diff --git a/fetch_podcasts.rb b/fetch_podcasts.rb index 49414c8..3936efd 100755 --- a/fetch_podcasts.rb +++ b/fetch_podcasts.rb @@ -1,717 +1,634 @@ #!/usr/bin/env jruby # frozen_string_literal: true # -# fetch_podcasts.rb - Podcast subscription management and episode fetching -# for the liquidsoap radio automation stack (JRuby/Sequel variant). +# fetch_podcasts.rb - Podcast subscription management for the radio automation. # -# Concurrency: exclusive non-blocking File.lock (state/radio.lock) shared with -# update_playlists.rb; skips cleanly if the sibling holds it. Databases run in -# WAL mode with a busy timeout as a second safety net. +# 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). # -# Politeness: audio/video verdict cached in shows.media_class; gpodder OPML -# pull uses bounded retry with exponential backoff + jitter. +# Usage: +# fetch_podcasts.rb --add-show +# fetch_podcasts.rb --list [--detail] +# fetch_podcasts.rb --remove +# fetch_podcasts.rb --fetch-all +# fetch_podcasts.rb --import-opml +# fetch_podcasts.rb --archive +# fetch_podcasts.rb --unarchive -# --------------------------------------------------------------------------- -# Gem path bootstrap: pin GEM_HOME/GEM_PATH before any require so the script -# finds its gems regardless of how it was launched (sudo strips them by -# default via env_reset). Derived from this file's own location so the -# scripts are relocatable. We PREPEND our .gems dir to the existing GEM_PATH -# rather than replacing it, so JRuby's shared/stdlib path stays reachable. -# Guards prevent overriding an explicit environment. -# --------------------------------------------------------------------------- -SCRIPT_DIR = File.expand_path(File.dirname(__FILE__)) -GEMS_DIR = File.join(SCRIPT_DIR, ".gems") -ENV["GEM_HOME"] = GEMS_DIR unless ENV["GEM_HOME"] -_existing_gem_path = ENV["GEM_PATH"].to_s.split(":").reject(&:empty?) -ENV["GEM_PATH"] = ([GEMS_DIR] + _existing_gem_path).uniq.join(":") -Gem.paths = { "GEM_HOME" => ENV["GEM_HOME"], "GEM_PATH" => ENV["GEM_PATH"] } - -require "sequel" -require "net/http" -require "openssl" require "json" -require "logger" -require "time" -require "fileutils" -require "cgi" -require "base64" -require "securerandom" -require "rexml/document" +require "net/http" +require "uri" +require "digest/md5" +require "sequel" +require "nokogiri" -ROOT = SCRIPT_DIR -CONFIG_PATH = File.join(ROOT, "config.json") - -AUDIO_EXTS = [".mp3", ".m4a"].freeze -VIDEO_EXTS = [".mp4", ".mov", ".avi", ".webm", ".mkv"].freeze - -$state_dir = nil -$subs_db_path = nil -$played_db_path = nil -$podcasts_dir = nil -$logs_dir = nil -$playlists_dir = nil -$lock_file = nil +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)) + raw = 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 -def init_paths! - cfg = load_config - storage = File.realpath(cfg["storage"]) - $state_dir = File.join(storage, "state") - $subs_db_path = File.join($state_dir, "subscriptions.db") - $played_db_path = File.join($state_dir, "played.db") - $podcasts_dir = File.join(storage, "podcasts") - $logs_dir = File.join(storage, "logs") - $playlists_dir = File.join(storage, "playlists") - $lock_file = File.join($state_dir, "radio.lock") -end +CFG = load_config +STORAGE_ROOT = CFG[:storage] +STATE_DIR = File.join(STORAGE_ROOT, "state") +LOGS_DIR = File.join(STORAGE_ROOT, "logs") +TMP_DIR = File.join(STATE_DIR, "tmp") +SUBS_DB = File.join(STATE_DIR, "subscriptions.db") +PLAYED_DB = File.join(STATE_DIR, "played.db") +LOG_FILE = File.join(LOGS_DIR, "fetch_cron.log") -$log = Logger.new(STDOUT) -$log.formatter = proc { |msg, _severity, _time, _progname| "#{Time.now} [INFO] #{msg}\n" } - -def log_error(msg) - $log.error(msg) -end - -def setup_logging! - $log.instance_variable_set(:@logdev, - Logger::LogDevice.new([STDOUT, File.join($logs_dir, "fetch.log")])) -end - -# --------------------------------------------------------------------------- -# Mutual exclusion via flock on a shared lockfile. -# --------------------------------------------------------------------------- -$lock_fh = nil - -def acquire_lock! - Dir.mkdir($state_dir) unless Dir.exist?($state_dir) - fh = File.open($lock_file, File::RDWR | File::CREAT, 0o644) - begin - fh.flock(File::LOCK_EX | File::LOCK_NB) - rescue Errno::EACCES, Errno::EAGAIN - fh.close - return false +def ensure_dir(path) + return if Dir.exist?(path) + parent = File.dirname(path) + unless path.start_with?("/") + raise ArgumentError, "ensure_dir requires an absolute path, got: #{path}" end - fh.truncate(0) - fh.write(Process.pid.to_s) - fh.rewind - $lock_fh = fh - true -end - -def release_lock! - return if $lock_fh.nil? - begin - $lock_fh.flock(File::LOCK_UN) - $lock_fh.close - ensure - $lock_fh = nil + 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 + nil + end + end end end -# --------------------------------------------------------------------------- -# Schema: single source of truth, applied idempotently via raw SQL through -# Database#execute. Raw DDL avoids the Sequel create_table/alter_table DSL, -# which is unreliable under JRuby (instance_exec'd generator methods can -# resolve to nil). Works identically on CRuby and JDBC. -# --------------------------------------------------------------------------- -SHOWS_SQL = <<~SQL.freeze - CREATE TABLE IF NOT EXISTS shows ( - slug TEXT PRIMARY KEY, - guid TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - feed_url TEXT NOT NULL UNIQUE, - source TEXT DEFAULT 'manual', - opml_import INTEGER DEFAULT 0, - archived INTEGER DEFAULT 1, - media_class TEXT, - created_at TEXT - ); -SQL +[STORAGE_ROOT, STATE_DIR, LOGS_DIR, TMP_DIR].each { |d| ensure_dir(d) } -EPISODES_SQL = <<~SQL.freeze - 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, - enclosure_url TEXT, - runlength INTEGER, - played INTEGER DEFAULT 0, - played_at TEXT, - UNIQUE (show_slug, guid) - ); -SQL +$log_fh = File.open(LOG_FILE, "a+") -INDEX_EPISODES_SQL = <<~SQL.freeze - CREATE INDEX IF NOT EXISTS idx_episodes_show_played ON episodes (show_slug, played); -SQL - -def tune(db) - # Plain-SQL pragmas via Database#execute, which works on CRuby and JRuby/JDBC - # across all modern Sequel versions (the :pragma extension is CRuby-only and - # db.sql requires Sequel >= 5.42). - db.execute("PRAGMA journal_mode=WAL;") - db.execute("PRAGMA busy_timeout=5000;") +def log(level, msg) + ts = Time.now.strftime("%Y-%m-%d %H:%M:%S") + line = "[#{ts}] [#{level}] #{msg}" + $log_fh.write(line + "\n") + $log_fh.flush + puts line +rescue Exception => e + puts "LOG ERROR: #{e.class} #{e.message}" end -def connect_subs - db = Sequel.connect("jdbc:sqlite:#{$subs_db_path}") - tune(db) - db.execute(SHOWS_SQL) - db +$db_s = Sequel.connect("jdbc:sqlite:" + SUBS_DB) +$db_p = Sequel.connect("jdbc:sqlite:" + PLAYED_DB) +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 -def connect_played - db = Sequel.connect("jdbc:sqlite:#{$played_db_path}") - tune(db) - db.execute(EPISODES_SQL) - db.execute(INDEX_EPISODES_SQL) - db -end - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- -def slugify(name) - s = name.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "") - s = "show" if s.empty? - s[0, 60] -end - -def gen_uuid - SecureRandom.uuid -end - -def classify_feed(entries) - return "unknown" if entries.nil? || entries.empty? - entry = entries.first - enclosures = entry[:enclosures] || [] - return "unknown" if enclosures.empty? - mime = (enclosures.first[:type] || "").downcase - return "audio" if mime.start_with?("audio/") - return "video" if mime.start_with?("video/") - url = (enclosures.first[:href] || "").downcase - return "audio" if AUDIO_EXTS.any? { |ext| url.end_with?(ext) } - return "video" if VIDEO_EXTS.any? { |ext| url.end_with?(ext) } - "unknown" -end - -def get_media_class(db, slug) - row = db[:shows].where(slug: slug).first - row ? row[:media_class] : nil -end - -def set_media_class(db, slug, cls) - db[:shows].where(slug: slug).update(media_class: cls) -end - -# --------------------------------------------------------------------------- -# Feed parsing (Net::HTTP + REXML) -# --------------------------------------------------------------------------- -def fetch_feed(feed_url) - uri = URI.parse(feed_url) - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = (uri.scheme == "https") - http.timeout = 60 - resp = http.request(Net::HTTP::Get.new(uri.request_uri)) - return nil unless resp.is_a?(Net::HTTPSuccess) - - doc = REXML::Document.new(resp.body) - channel = doc.elements["//channel"] - return nil if channel.nil? - - title = channel.elements["title"]&.text - entries = [] - channel.get_elements("./item").each do |item| - link_el = item.elements["link"] - title_el = item.elements["title"] - guid_el = item.elements["guid"] - dur_el = item.elements["media:duration"] || item.elements["itunes:duration"] - enc_el = item.elements["enclosure"] - - enclosures = [] - if enc_el - enclosures << { - href: enc_el.attributes["url"], - type: enc_el.attributes["type"], - length: enc_el.attributes["length"] - } +def ensure_schema + begin + if table_count($db_s, "shows").zero? + $db_s.execute <<-SQL + CREATE TABLE shows ( + guid TEXT PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + feed_url TEXT NOT NULL, + audio_only INTEGER DEFAULT 1, + archive INTEGER DEFAULT 0, + opml_import INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + SQL + log("INFO", "Created 'shows' table") end - entries << { - link: link_el&.text, - title: title_el&.text, - guid: guid_el&.text, - enclosures: enclosures, - duration: dur_el&.text + if table_count($db_p, "episodes").zero? + $db_p.execute <<-SQL + CREATE TABLE episodes ( + guid TEXT PRIMARY KEY, + show_guid TEXT NOT NULL, + title TEXT, + enclosure_url TEXT, + duration_seconds INTEGER, + published_date TEXT, + played INTEGER DEFAULT 0, + downloaded INTEGER DEFAULT 0, + local_path TEXT + ) + SQL + log("INFO", "Created 'episodes' table") + 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 + +ensure_schema + +def make_slug(title_str, feed_url) + base = title_str.to_s.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/^_+|_+$/, "") + 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 + +def gen_guid(seed_str) + Digest::MD5.hexdigest(seed_str) +end + +def http_stream_to_file(url, dest_path, user = nil, pass = nil) + uri = URI.parse(url) + req = Net::HTTP::Get.new(uri.path + (uri.query ? "?#{uri.query}" : "")) + if user && pass + req.basic_auth(user, pass) + end + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == "https") + http.open_timeout = 30 + http.read_timeout = 60 + success = false + begin + http.request(req) do |r| + if r.is_a?(Net::HTTPSuccess) + File.open(dest_path, "wb") do |f| + r.read_body { |chunk| f.write(chunk) } + end + size = File.size(dest_path) + log("INFO", "Downloaded #{size} bytes from #{url}") + success = true + else + log("ERROR", "HTTP #{r.code} fetching #{url}") + end + end + rescue Exception => e + log("ERROR", "Failed to download #{url}: #{e.class} #{e.message}") + end + success +end + +def parse_feed(data={}) + result = {:title => "Untitled Show", :description => "", :episodes => []} + unless data.is_a?(::Hash) + 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 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 + + +def parse_feed_from_file(file_path="", feed_url="") + doc = Nokogiri::XML(File.read(file_path)) + channel = doc.at_xpath("//channel") + if channel.nil? + log("ERROR", "No found in feed at #{feed_url}") + return nil + end + + title_el = channel.at_xpath("./title") + 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| + ep_title_el = item.at_xpath("./title") + enc_el = item.at_xpath("./enclosure") + guid_el = item.at_xpath("./guid") + dur_el = item.at_xpath(".//duration") + pub_el = item.at_xpath("./pubDate") + + ep_title = ep_title_el ? ep_title_el.text.strip : "Untitled" + enc_url = enc_el ? enc_el.attribute("url").to_s.strip : "" + enc_type = enc_el ? enc_el.attribute("type").to_s.strip : "" + enc_len = enc_el ? enc_el.attribute("length").to_s.strip.to_i : 0 + ep_guid = guid_el ? guid_el.text.strip : "" + 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 + + if ep_guid.empty? + ep_guid = gen_guid("#{enc_url}#{ep_title}") + end + + if ep_dur <= 0 && enc_len > 0 + ep_dur = (enc_len / (128 * 1024)).to_i + end + + episodes << { + :guid => ep_guid, + :title => ep_title, + :url => enc_url, + :duration_seconds => ep_dur, + :published_at => ep_pub, + :file_size_bytes => enc_len } end - { title: title, entries: entries } -rescue StandardError => e - log_error("Feed parse error for #{feed_url}: #{e.message}") - nil + { + :title => show_title, + :description => show_desc, + :episodes => episodes + } end -# --------------------------------------------------------------------------- -# gPodder sync with bounded retry + exponential backoff + jitter -# --------------------------------------------------------------------------- -def gpodder_sync(cfg) - g = cfg["gpodder"] - base = g["host"].chomp("/") - username = g["username"] - password = g["password"] - path = "/subscriptions/#{CGI.escape(username)}.opml" - - puts "--- Syncing subscriptions from #{base} ---" - puts "Fetching subscriptions for '#{username}'..." - - max_attempts = 4 - backoff_base = 2.0 - resp = nil - - max_attempts.times do |attempt| - begin - uri = URI.parse("#{base}#{path}") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = (uri.scheme == "https") - http.timeout = 60 - req = Net::HTTP::Get.new(uri.request_uri) - credentials = Base64.strict_encode64("#{username}:#{password}") - req["Authorization"] = "Basic #{credentials}" - req["User-Agent"] = "radio-automation/1.0" - resp = http.request(req) - break - rescue StandardError => e - if attempt == max_attempts - 1 - log_error("gPodder sync failed after #{attempt + 1} attempts: #{e.class.name}") - return [] - end - delay = (backoff_base ** (attempt + 1)) + rand - $log.warn("gPodder request error (#{e.class.name}); retrying in #{delay.round(1)}s") - sleep(delay) - end +def register_show(parsed, feed_url) + slug = make_slug(parsed[:title], feed_url) + show_guid = gen_guid(feed_url) + existing = $db_s[:shows].where(slug: slug).first + if existing + log("INFO", "Show already registered: #{parsed[:title]} (#{slug})") + return existing[:guid] end - return [] if resp.nil? + $db_s[:shows].insert(guid: show_guid, slug: slug, title: parsed[:title], feed_url: feed_url, audio_only: 1, archive: 0) + log("INFO", "Registered show: #{parsed[:title]} (#{slug})") - case resp.code.to_i - when 200 - body = resp.body - return [] if body.strip.empty? - 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 429 - log_error("gPodder sync throttled (429). Will retry next cycle.") - else - code = resp.code.to_i - if code >= 500 - log_error("gPodder sync server error (#{code}). Will retry next cycle.") - else - log_error("gPodder sync failed: unexpected response #{code}: #{resp.body[0, 200]}") - end - end - [] -end - -def parse_opml(xml_string) - shows = [] - doc = REXML::Document.new(xml_string) - doc.elements.each("//outline") do |outline| - feed_url = (outline.attributes["xmlUrl"] || "").strip - name = (outline.attributes["text"] || "").strip - guid = (outline.attributes["guid"] || "").strip - next unless feed_url.match?(/\Ahttps?:\/\//) - shows << { name: name, feed_url: feed_url, guid: guid.empty? ? nil : guid } - end - shows -rescue REXML::ParseException => e - log_error("Failed to parse OPML XML: #{e.message}") - [] -end - -# --------------------------------------------------------------------------- -# Show registration / pruning -# --------------------------------------------------------------------------- -def register_remote_shows(remote_shows) - db = connect_subs - added = 0 - skipped_video = 0 - remote_shows.each do |show| - slug = slugify(show[:name]) - next if db[:shows].where(slug: slug).count > 0 - - parsed = fetch_feed(show[:feed_url]) - if parsed.nil? - $log.warn("Skipping '#{show[:name]}': could not fetch feed.") - next - end - cls = classify_feed(parsed[:entries]) - if cls == "video" - $log.info("Skipping '#{show[:name]}' (#{slug}): video podcast, not audio.") - skipped_video += 1 - next - end - guid = show[:guid] || gen_uuid - db[:shows].insert( - slug: slug, guid: guid, name: show[:name], feed_url: show[:feed_url], - source: "gpodder", opml_import: 0, archived: 1, media_class: cls + 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.info("Registered new show: #{show[:name]} (#{slug}) [#{cls}]") - added += 1 end - db.disconnect - $log.info("Filtered out #{skipped_video} video podcast(s).") if skipped_video > 0 - added + + log("INFO", "Stored #{parsed[:episodes].size} episodes for #{slug}") + show_guid end -def prune_stale_shows(remote_shows) - db = connect_subs - remote_slugs = remote_shows.map { |s| slugify(s[:name]) }.to_set - stale = db[:shows].where(source: "gpodder", opml_import: 0).all - removed = 0 - stale.each do |row| - next if remote_slugs.include?(row[:slug]) - remove_show_data(row[:slug]) - db[:shows].where(slug: row[:slug]).delete - $log.info("Pruned stale show: #{row[:name]} (#{row[:slug]})") - removed += 1 +def cmd_add_show(url) + log("INFO", "Adding show: #{url}") + tmp_file = File.join(TMP_DIR, "feed_#{Process.pid}.xml") + ok = http_stream_to_file(url, tmp_file) + unless ok + log("ERROR", "Could not download feed: #{url}") + exit 1 end - db.disconnect - removed -end - -# --------------------------------------------------------------------------- -# Episode extraction / download -# --------------------------------------------------------------------------- -def extract_duration(entry) - dur = entry[:duration] - if dur - return dur.to_i if dur.match?(/\A\d+\z/) - m = dur.match(/\APT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?\z/i) - if m - h = m[1] ? m[1].to_i : 0 - mn = m[2] ? m[2].to_i : 0 - s = m[3] ? m[3].to_i : 0 - return h * 3600 + mn * 60 + s - end - end - enc = entry[:enclosures]&.first - if enc && enc[:length] - bytes = enc[:length].to_i - return (bytes * 8 / 128_000) if bytes > 0 - end - nil -end - -def download_episode(url, dest_dir, filename) - 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.timeout = 120 - tmp = "#{dest}.part" + # ??? + parsed = parse_feed({:file_path => tmp_file, :feed_url => url}) + # parsed = parse_feed_from_file(tmp_file, url) begin - http.request_get(uri.request_uri) do |response| - raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess) - File.open(tmp, "wb") do |f| - response.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) + File.delete(tmp_file) + rescue Errno::ENOENT nil end -end -def safe_filename(title, fallback) - name = (title || "").gsub(/[^\w\s.\-]/, "").strip.tr(" ", "_") - name = fallback if name.empty? - "#{name[0, 120]}.mp3" -end - -def show_archived?(db, slug) - row = db[:shows].where(slug: slug).first - row.nil? ? true : row[:archived] == 1 -end - -# --------------------------------------------------------------------------- -# Per-show episode fetch -# --------------------------------------------------------------------------- -def fetch_show_episodes(slug, name, feed_url) - dest_dir = File.join($podcasts_dir, slug) - Dir.mkdir(dest_dir) unless Dir.exist?(dest_dir) - - subs_db = connect_subs - - cached_cls = get_media_class(subs_db, slug) - if cached_cls == "video" - subs_db.disconnect - return 0 - end - - parsed = fetch_feed(feed_url) if parsed.nil? - subs_db.disconnect - return 0 + log("ERROR", "Could not parse feed: #{url}") + exit 1 end - if cached_cls.nil? - cls = classify_feed(parsed[:entries]) - set_media_class(subs_db, slug, cls) - if cls == "video" - $log.info("'#{name}' (#{slug}) classified as video; skipping.") - subs_db.disconnect - return 0 - end - end - - played_db = connect_played - seen = played_db[:episodes].where(show_slug: slug).select_map(:guid).to_set - archived = show_archived?(subs_db, slug) - new_count = 0 - - parsed[:entries].each do |entry| - guid = entry[:guid] || entry[:link] || entry[:title] || "" - next if seen.include?(guid) - enclosures = entry[:enclosures] || [] - next if enclosures.empty? - mime = (enclosures.first[:type] || "").downcase - next if mime.start_with?("video/") - audio_url = enclosures.first[:href] - next if audio_url.nil? || audio_url.empty? - - title = entry[:title] || "untitled" - duration = extract_duration(entry) - - if archived - filename = safe_filename(title, guid[-20..]) - file_path = download_episode(audio_url, dest_dir, filename) - next if file_path.nil? - insert_episode(played_db, slug, guid, title, file_path, audio_url, duration) - else - insert_episode(played_db, slug, guid, title, nil, audio_url, duration) - end - new_count += 1 - kind = archived ? "downloaded" : "live" - $log.info(" New episode: #{title} [#{kind}]") - end - - played_db.disconnect - subs_db.disconnect - new_count + register_show(parsed, url) + log("INFO", "Done adding show: #{parsed[:title]}") end -def insert_episode(db, slug, guid, title, file_path, enclosure_url, duration) - # INSERT OR IGNORE semantics via the UNIQUE(show_slug, guid) constraint. - db.transaction do - db[:episodes].insert( - show_slug: slug, guid: guid, title: title, - file_path: file_path, enclosure_url: enclosure_url, - runlength: duration, played: 0 - ) - end -rescue Sequel::UniqueConstraintViolation - # Already recorded; ignore. -end - -def fetch_all_episodes - db = connect_subs - shows = db[:shows].order(:name).all - db.disconnect - total_new = 0 - shows.each do |show| - $log.info("--- Fetching: #{show[:name]} (#{show[:slug]}) ---") - begin - n = fetch_show_episodes(show[:slug], show[:name], show[:feed_url]) - total_new += n - rescue StandardError => e - log_error("Unexpected error fetching #{show[:slug]}: #{e.message}") - end - end - $log.info("=== Fetch complete: #{total_new} new episode(s) ===") -end - -# --------------------------------------------------------------------------- -# Administrative commands -# --------------------------------------------------------------------------- -def list_shows(detail: false) - db = connect_subs - rows = db[:shows].order(:name).all - db.disconnect +def cmd_list(detail) + rows = $db_s[:shows].all if rows.empty? puts "No shows registered." return end - puts format("%-30s %-10s %-8s %-10s %s", "SLUG", "ARCHIVED", "MEDIA", "SOURCE", "NAME") rows.each do |r| - arch = r[:archived] == 1 ? "yes" : "no" - media = r[:media_class] || "?" - line = format("%-30s %-10s %-8s %-10s %s", r[:slug], arch, media, r[:source], r[:name]) - line += "\n" + (" " * 50) + r[:feed_url] if detail + line = "%-30s %-40s arch=%d" % [r[:slug], r[:title], r[:archive]] + if detail + ep_count = $db_p[:episodes].where(show_guid: r[:guid]).count + played_count = $db_p[:episodes].where(show_guid: r[:guid], played: 1).count + line += " eps=#{ep_count} played=#{played_count}" + end puts line end end -def add_show(feed_url) - parsed = fetch_feed(feed_url) - if parsed.nil? || parsed[:title].nil? - log_error("Could not determine show title from #{feed_url}") - return - end - cls = classify_feed(parsed[:entries]) - if cls == "video" - log_error("Refusing to add '#{parsed[:title]}': video podcast detected.") - return - end - name = parsed[:title] - slug = slugify(name) - guid = gen_uuid - db = connect_subs - begin - db[:shows].insert( - slug: slug, guid: guid, name: name, feed_url: feed_url, - source: "manual", opml_import: 0, archived: 1, media_class: cls - ) - rescue Sequel::UniqueConstraintViolation - # already present - end - db.disconnect - $log.info("Added show: #{name} (#{slug}) [#{cls}]") - fetch_show_episodes(slug, name, feed_url) -end - -def set_archive(slug, value) - db = connect_subs - row = db[:shows].where(slug: slug).first +def cmd_remove(slug_or_url) + row = $db_s[:shows].where(Sequel.or({slug: slug_or_url}, {feed_url: slug_or_url})).first if row.nil? - log_error("No show found with slug '#{slug}'.") - db.disconnect + 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_fetch_all + rows = $db_s[:shows].all + if rows.empty? + log("INFO", "No shows to fetch.") return end - db[:shows].where(slug: slug).update(archived: value) - db.disconnect - state = value == 1 ? "archived" : "non-archived (live)" - $log.info("Show '#{row[:name]}' (#{slug}) is now #{state}.") -end - -def remove_show_data(slug) - pod_dir = File.join($podcasts_dir, slug) - FileUtils.rm_rf(pod_dir) if Dir.exist?(pod_dir) - txt = File.join($playlists_dir, "#{slug}.txt") - File.delete(txt) if File.exist?(txt) -end - -def delete_show(slug) - db = connect_subs - row = db[:shows].where(slug: slug).first - if row.nil? - log_error("No show found with slug '#{slug}'.") - db.disconnect - return - end - remove_show_data(slug) - db[:shows].where(slug: slug).delete - db.disconnect - played_db = connect_played - played_db[:episodes].where(show_slug: slug).delete - played_db.disconnect - $log.info("Deleted show: #{row[:name]} (#{slug})") -end - -def import_opml(path) - content = File.read(path) - shows = parse_opml(content) - db = connect_subs - added = 0 - skipped_video = 0 - shows.each do |show| - slug = slugify(show[:name]) - next if db[:shows].where(slug: slug).count > 0 - parsed = fetch_feed(show[:feed_url]) + rows.each do |show| + log("INFO", "Fetching: #{show[:title]} (#{show[:slug]})") + tmp_file = File.join(TMP_DIR, "feed_#{show[:slug]}_#{Process.pid}.xml") + ok = http_stream_to_file(show[:feed_url], tmp_file) + unless ok + log("ERROR", "Could not download feed: #{show[:feed_url]}") + next + end + # ??? + parsed = parse_feed({:file_path => tmp_file, :feed_url => show[:feed_url]}) + # parsed = parse_feed_from_file(tmp_file, show[:feed_url]) + begin + File.delete(tmp_file) + rescue Errno::ENOENT + nil + end if parsed.nil? - $log.warn("OPML import: skipping '#{show[:name]}', could not fetch feed.") + log("ERROR", "Could not parse feed: #{show[:feed_url]}") next end - cls = classify_feed(parsed[:entries]) - if cls == "video" - $log.info("OPML import: skipping '#{show[:name]}' (#{slug}): video podcast.") - skipped_video += 1 - next + new_eps = 0 + parsed[:episodes].each do |ep| + existing = $db_p[:episodes].where(guid: ep[:guid]).first + if existing.nil? + $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] + ) + new_eps += 1 + end end - guid = show[:guid] || gen_uuid - db[:shows].insert( - slug: slug, guid: guid, name: show[:name], feed_url: show[:feed_url], - source: "opml", opml_import: 1, archived: 1, media_class: cls - ) - added += 1 + log("INFO", "#{new_eps} new episodes for #{show[:slug]}") end - db.disconnect - $log.info("OPML import: #{added} added, #{skipped_video} video shows filtered out.") end -def run_fetch(config) - g = config["gpodder"] - if g["enable"] == true - remote = gpodder_sync(config) - if remote.empty? - $log.warn("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.") +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 - fetch_all_episodes + + 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 - option = args.shift - - init_paths! - [$state_dir, $logs_dir, $podcasts_dir, $playlists_dir].each do |dir| - Dir.mkdir(dir) unless Dir.exist?(dir) - end - setup_logging! - - config = load_config - - case option - when "--list-shows" - list_shows(detail: args.include?("--detail")) + command = args.shift + sync_gpodder + case command when "--add-show" - add_show(args.first) - when "--delete-show" - delete_show(args.first) - when "--archive" - set_archive(args.first, 1) - when "--unarchive" - set_archive(args.first, 0) + url = args.shift + if url.nil? + puts "Usage: fetch_podcasts.rb --add-show " + exit 1 + end + cmd_add_show(url) + when "--list" + detail = args.include?("--detail") + cmd_list(detail) + when "--remove" + slug = args.shift + if slug.nil? + puts "Usage: fetch_podcasts.rb --remove " + exit 1 + end + cmd_remove(slug) + when "--fetch-all" + cmd_fetch_all when "--import-opml" - import_opml(args.first) + opml_file = args.shift + if opml_file.nil? + puts "Usage: fetch_podcasts.rb --import-opml " + exit 1 + end + cmd_import_opml(opml_file) + when "--archive" + slug = args.shift + if slug.nil? + puts "Usage: fetch_podcasts.rb --archive " + exit 1 + end + cmd_archive(slug) + when "--unarchive" + slug = args.shift + if slug.nil? + puts "Usage: fetch_podcasts.rb --unarchive " + exit 1 + end + cmd_unarchive(slug) else - if !acquire_lock! - $log.info("Another radio process holds the lock; skipping this run.") - return - end - begin - run_fetch(config) - ensure - release_lock! - end + puts "Usage: fetch_podcasts.rb [--add-show |--list|--remove |--fetch-all|--import-opml |--archive |--unarchive ]" end end main if __FILE__ == $PROGRAM_NAME + diff --git a/install_for_jruby b/install_for_jruby index 655e4c1..4267012 100755 --- a/install_for_jruby +++ b/install_for_jruby @@ -1,293 +1,151 @@ -#!/usr/bin/env bash -# -# install_for_jruby - Provision the JRuby radio automation stack. -# Idempotent: safe to re-run. Derives the service name from this file's -# containing directory basename. Must be run as root. +#!/bin/bash +# install_for_jruby - Installer for the JRuby radio automation stack. # +# Derives its service name from the directory it lives in, e.g. +# /srv/radio/install_for_jruby -> radio.service +# Prompts for a storage root and writes a complete config.json. +# Creates media subdirectories (music, podcasts, jingles, announcements), +# state/ and logs/ under the storage root, drops and recreates all +# database files, installs gems one at a time, generates a systemd unit, +# and adds cron entries to the existing liquidsoap user's crontab. set -euo pipefail -if [[ $EUID -ne 0 ]]; then - echo "ERROR: this installer must be run as root." >&2 +INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)" +SERVICE_BASENAME="$(basename "${INSTALL_DIR}")" +SERVICE_NAME="${SERVICE_BASENAME}.service" +UNIT_PATH="/etc/systemd/system/${SERVICE_NAME}" + +CONFIG_JSON="${INSTALL_DIR}/config.json" +GEMS_DIR="${INSTALL_DIR}/.gems" +JRUBY_BIN="" + +echo "=== ${SERVICE_BASENAME} installer ===" +echo + +# --- Detect JRuby ----------------------------------------------------------- +if command -v jruby >/dev/null 2>&1; then + JRUBY_BIN="$(command -v jruby)" +elif [ -x /opt/jruby/bin/jruby ]; then + JRUBY_BIN="/opt/jruby/bin/jruby" +else + echo "ERROR: jruby not found on PATH or at /opt/jruby/bin/jruby." >&2 exit 1 fi +echo "==> Using JRuby: ${JRUBY_BIN}" +echo -INSTALL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SERVICE_NAME="$(basename "$INSTALL_DIR")" -UNIT_FILE="/etc/systemd/system/${SERVICE_NAME}.service" -LIQUIDSOAP_USER="liquidsoap" -JRUBY_HOME_PINNED="/opt/jruby" -JRUBY_VERSION="10.1.1.0" -GEMS_DIR="${INSTALL_DIR}/.gems" +# --- Prompt for storage root ------------------------------------------------ +STORAGE_ROOT_DEFAULT="/mnt/storage/radio" +printf "Storage root [%s]: " "${STORAGE_ROOT_DEFAULT}" +read -r STORAGE_ROOT_INPUT +STORAGE_ROOT="${STORAGE_ROOT_INPUT:-${STORAGE_ROOT_DEFAULT}}" +echo "==> Storage root: ${STORAGE_ROOT}" -echo "==> Installing ${SERVICE_NAME} (JRuby stack) from ${INSTALL_DIR}" +# --- Create media and state directories ------------------------------------- +for d in music podcasts jingles announcements state logs; do + mkdir -p "${STORAGE_ROOT}/${d}" +done +chown -R liquidsoap:liquidsoap "${STORAGE_ROOT}" 2>/dev/null || true +echo "==> Media/state/log directories created under ${STORAGE_ROOT}" -# --------------------------------------------------------------------------- -# 1. Base packages -# --------------------------------------------------------------------------- -echo "==> Ensuring base packages..." -export DEBIAN_FRONTEND=noninteractive -apt-get update -qq -apt-get install -y -qq \ - liquidsoap icecast2 jq curl unzip ca-certificates >/dev/null +# --- Drop and recreate databases -------------------------------------------- +rm -f "${STORAGE_ROOT}/state/subscriptions.db" \ + "${STORAGE_ROOT}/state/played.db" \ + "${STORAGE_ROOT}/state/subscriptions.db-wal" \ + "${STORAGE_ROOT}/state/subscriptions.db-shm" \ + "${STORAGE_ROOT}/state/played.db-wal" \ + "${STORAGE_ROOT}/state/played.db-shm" +echo "==> Databases dropped (will be recreated by scripts on first run)" -if ! id -u "$LIQUIDSOAP_USER" >/dev/null 2>&1; then - useradd --system --create-home --shell /usr/sbin/nologin "$LIQUIDSOAP_USER" - echo " created system user '${LIQUIDSOAP_USER}'" -fi +# --- Install gems one at a time --------------------------------------------- +export GEM_HOME="${GEMS_DIR}" +export GEM_PATH="${GEMS_DIR}" +mkdir -p "${GEMS_DIR}" -# --------------------------------------------------------------------------- -# 2. Java detection / install -# --------------------------------------------------------------------------- -have_java() { - command -v java >/dev/null 2>&1 || return 1 - local major - major=$(java -version 2>&1 | awk -F '"' '/version/ {print $2}' | cut -d. -f1) - [[ "$major" == "1" ]] && major=$(java -version 2>&1 | awk -F '"' '/version/ {print $2}' | cut -d. -f2) - [[ -n "$major" && "$major" -ge 21 ]] -} - -if have_java; then - echo "==> Reusing existing JVM ($(java -version 2>&1 | head -1))." -else - echo "==> No suitable JVM found; installing OpenJDK 21 headless..." - apt-get install -y -qq openjdk-21-jdk-headless >/dev/null -fi - -# --------------------------------------------------------------------------- -# 3. JRuby detection / install -# --------------------------------------------------------------------------- -have_jruby() { - if [[ -x "${JRUBY_HOME_PINNED}/bin/jruby" ]]; then - echo "${JRUBY_HOME_PINNED}/bin/jruby"; return 0 - fi - if command -v jruby >/dev/null 2>&1; then - command -v jruby; return 0 - fi - return 1 -} - -if JRUBY_BIN=$(have_jruby); then - echo "==> Reusing existing JRuby at ${JRUBY_BIN}." -else - echo "==> Downloading JRuby ${JRUBY_VERSION} into ${JRUBY_HOME_PINNED} ..." - mkdir -p "$JRUBY_HOME_PINNED" - TARBALL="/tmp/jruby-${JRUBY_VERSION}-bin.tar.gz" - URL="https://repo1.maven.org/maven2/org/jruby/jruby-dist/${JRUBY_VERSION}/jruby-dist-${JRUBY_VERSION}-bin.tar.gz" - curl -fsSL "$URL" -o "$TARBALL" - tar -xzf "$TARBALL" -C "$JRUBY_HOME_PINNED" --strip-components=1 - rm -f "$TARBALL" - JRUBY_BIN="${JRUBY_HOME_PINNED}/bin/jruby" -fi - -# --------------------------------------------------------------------------- -# 4. Register via update-alternatives -# --------------------------------------------------------------------------- -echo "==> Integrating JRuby via update-alternatives ..." -for tool in jruby gem bundle rake irb; do - target="${JRUBY_BIN%/*}/${tool}" - if [[ -x "$target" ]]; then - ln -sf "$target" "/usr/local/bin/${tool}" - update-alternatives --install "/usr/local/bin/${tool}" "${tool}" "$target" 100 || true +for gem_name in sequel jdbc-sqlite3 json nokogiri; do + echo "==> Installing gem: ${gem_name}" + if ! "${JRUBY_BIN}" -S gem install --local "${gem_name}" 2>/dev/null; then + "${JRUBY_BIN}" -S gem install "${gem_name}" fi done -# --------------------------------------------------------------------------- -# 5. Gems (one at a time, raised heap). Order matters: jdbc-sqlite3 first so -# it's present when sequel loads its JDBC SQLite subadapter. -# --------------------------------------------------------------------------- -export GEM_HOME="$GEMS_DIR" -export GEM_PATH="$GEMS_DIR" -export JRUBY_OPTS="-J-Xmx1g -J-Xss512k" +# --- Write complete config.json ---------------------------------------------- +ICECAST_HOST_DEFAULT="192.168.0.200" +ICECAST_PORT_DEFAULT="7777" +ICECAST_MOUNT_DEFAULT="/data" +ICECAST_SOURCE_USER_DEFAULT="source" +GPODDER_ENABLE_DEFAULT="false" +GPODDER_HOST_DEFAULT="gpodder.net" -echo "==> Installing gems one at a time into ${GEMS_DIR} ..." -"$JRUBY_BIN" -S gem install --no-document jdbc-sqlite3 -"$JRUBY_BIN" -S gem install --no-document sequel -"$JRUBY_BIN" -S gem install --no-document json - -chown -R "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$GEMS_DIR" - -# --------------------------------------------------------------------------- -# 6. Interactive configuration (defaults from existing config.json) -# --------------------------------------------------------------------------- -CONFIG_JSON="${INSTALL_DIR}/config.json" - -prompt() { - local prompt_text="$1" default="${2:-}" - local current="" - if [[ -n "$default" ]]; then - read -rp "${prompt_text} [${default}]: " current || true - echo "${current:-$default}" - else - read -rp "${prompt_text}: " current || true - echo "$current" +printf "Icecast host [%s]: " "${ICECAST_HOST_DEFAULT}" +read -r ICECAST_HOST_INPUT; ICECAST_HOST="${ICECAST_HOST_INPUT:-${ICECAST_HOST_DEFAULT}}" +printf "Icecast source port [%s]: " "${ICECAST_PORT_DEFAULT}" +read -r ICECAST_PORT_INPUT; ICECAST_PORT="${ICECAST_PORT_INPUT:-${ICECAST_PORT_DEFAULT}}" +printf "Mount point [%s]: " "${ICECAST_MOUNT_DEFAULT}" +read -r ICECAST_MOUNT_INPUT; ICECAST_MOUNT="${ICECAST_MOUNT_INPUT:-${ICECAST_MOUNT_DEFAULT}}" +printf "Source username [%s]: " "${ICECAST_SOURCE_USER_DEFAULT}" +read -r ICECAST_SRC_USER_INPUT; ICECAST_SRC_USER="${ICECAST_SRC_USER_INPUT:-${ICECAST_SOURCE_USER_DEFAULT}}" +printf "Source password: " +read -rs ICECAST_SRC_PASS +echo +printf "gPodder sync enabled? (y/n) [n]: " +read -r GP_SYNC_INPUT +case "${GP_SYNC_INPUT,,}" in + y|yes) GP_ENABLED=true ;; + *) GP_ENABLED=false ;; +esac +GP_HOST="${GPODDER_HOST_DEFAULT}" +GP_USERNAME="" +GP_PASSWORD="" +GP_DEVICE_ID="" +if [ "${GP_ENABLED}" = "true" ]; then + printf "gPodder username: " + read -r GP_USERNAME + printf "gPodder password: " + read -rs GP_PASSWORD + echo + printf "gPodder device ID (leave blank to generate): " + read -r GP_DEVICE_ID + if [ -z "${GP_DEVICE_ID}" ]; then + GP_DEVICE_ID="radio-$(date +%s)-$$" fi -} - -existing_storage="" ; existing_ic_host="" ; existing_ic_port="" -existing_ic_mount="" ; existing_ic_user="" ; existing_ic_pass="" -existing_gp_enable="" ; existing_gp_host="" ; existing_gp_user="" ; existing_gp_pass="" - -if [[ -f "$CONFIG_JSON" ]]; then - echo "==> Found existing config.json; using it for defaults." - existing_storage=$(jq -r '.storage // empty' "$CONFIG_JSON") - existing_ic_host=$(jq -r '.icecast.host // empty' "$CONFIG_JSON") - existing_ic_port=$(jq -r '.icecast.port // empty' "$CONFIG_JSON") - existing_ic_mount=$(jq -r '.icecast.mount // empty' "$CONFIG_JSON") - existing_ic_user=$(jq -r '.icecast.username // empty' "$CONFIG_JSON") - existing_ic_pass=$(jq -r '.icecast.password // empty' "$CONFIG_JSON") - existing_gp_enable=$(jq -r '.gpodder.enable // empty' "$CONFIG_JSON") - existing_gp_host=$(jq -r '.gpodder.host // empty' "$CONFIG_JSON") - existing_gp_user=$(jq -r '.gpodder.username // empty' "$CONFIG_JSON") - existing_gp_pass=$(jq -r '.gpodder.password // empty' "$CONFIG_JSON") fi -echo "" -echo "--- Storage ---" -STORAGE_PATH=$(prompt "Storage path (media + state + logs)" "${existing_storage:-/mnt/storage/radio}") -[[ -z "$STORAGE_PATH" ]] && STORAGE_PATH="${existing_storage:-/mnt/storage/radio}" - -echo "" -echo "--- Icecast (source credentials) ---" -IC_HOST=$(prompt "Icecast host" "$existing_ic_host"); IC_HOST=${IC_HOST:-localhost} -IC_PORT=$(prompt "Icecast source port" "$existing_ic_port"); IC_PORT=${IC_PORT:-7777} -IC_MOUNT=$(prompt "Mount point" "$existing_ic_mount"); IC_MOUNT=${IC_MOUNT:-/data} -IC_USER=$(prompt "Source username" "$existing_ic_user"); IC_USER=${IC_USER:-source} -read -rsp "Source password: " IC_PASS || true; echo; IC_PASS=${IC_PASS:-$existing_ic_pass} - -echo "" -echo "--- gPodder sync (optional) ---" -GP_ENABLE=$(prompt "Enable gPodder sync? (true/false)" "$existing_gp_enable"); GP_ENABLE=${GP_ENABLE:-false} -GP_HOST=$(prompt "gPodder host" "$existing_gp_host"); GP_HOST=${GP_HOST:-https://gpodder.net} -GP_USER=$(prompt "gPodder username" "$existing_gp_user") -read -rsp "gPodder password: " GP_PASS || true; echo; GP_PASS=${GP_PASS:-$existing_gp_pass} - -echo "" -echo "Summary:" -echo " Storage : $STORAGE_PATH" -echo " Icecast : $IC_USER@$IC_HOST:$IC_PORT mount=$IC_MOUNT" -echo " gPodder : enabled=$GP_ENABLE ($GP_USER @ $GP_HOST)" -read -rp "Proceed? [y/N]: " confirm || true -case "$confirm" in - [Yy]*) ;; - *) echo "Aborted."; exit 0 ;; -esac - -# --------------------------------------------------------------------------- -# 7. Directory tree -# --------------------------------------------------------------------------- -echo "==> Creating directory tree under $STORAGE_PATH ..." -mkdir -p "$STORAGE_PATH"/{music,podcasts,jingles,announcements,state,playlists,logs} -chown -R "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$STORAGE_PATH" - -# --------------------------------------------------------------------------- -# 8. Write config.json (mode 600) -# --------------------------------------------------------------------------- -echo "==> Writing $CONFIG_JSON ..." -cat > "$CONFIG_JSON" < "${CONFIG_JSON}" < Wrote ${CONFIG_JSON}" -# --------------------------------------------------------------------------- -# 9. Initialize BOTH SQLite databases with the full current schema. -# Uses raw CREATE TABLE IF NOT EXISTS via Database#execute - avoids the -# Sequel create_table DSL, which is unreliable under JRuby (instance_exec'd -# generator methods can resolve to nil). Works on CRuby and JDBC alike. -# --------------------------------------------------------------------------- -echo "==> Initializing SQLite databases via Sequel/jdbc-sqlite3 ..." -GEM_HOME="$GEMS_DIR" GEM_PATH="$GEMS_DIR" "$JRUBY_BIN" -e ' -require "sequel" - -state_dir = ARGV[0] -subs_path = File.join(state_dir, "subscriptions.db") -played_path = File.join(state_dir, "played.db") - -def tune(db) - db.execute("PRAGMA journal_mode=WAL;") - db.execute("PRAGMA busy_timeout=5000;") -end - -SHOWS_SQL = <<~SQL - CREATE TABLE IF NOT EXISTS shows ( - slug TEXT PRIMARY KEY, - guid TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - feed_url TEXT NOT NULL UNIQUE, - source TEXT DEFAULT '"'"'manual'"'"', - opml_import INTEGER DEFAULT 0, - archived INTEGER DEFAULT 1, - media_class TEXT, - created_at TEXT - ); -SQL - -EPISODES_SQL = <<~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, - enclosure_url TEXT, - runlength INTEGER, - played INTEGER DEFAULT 0, - played_at TEXT, - UNIQUE (show_slug, guid) - ); -SQL - -INDEX_SQL = <<~SQL - CREATE INDEX IF NOT EXISTS idx_episodes_show_played ON episodes (show_slug, played); -SQL - -db = Sequel.connect("jdbc:sqlite:#{subs_path}") -tune(db) -db.execute(SHOWS_SQL) -db.disconnect - -db = Sequel.connect("jdbc:sqlite:#{played_path}") -tune(db) -db.execute(EPISODES_SQL) -db.execute(INDEX_SQL) -db.disconnect - -puts " subscriptions.db and played.db initialized." -' "$STORAGE_PATH/state" -chown -R "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$STORAGE_PATH/state" - -# --------------------------------------------------------------------------- -# 10. systemd unit (named after the directory) -# --------------------------------------------------------------------------- -echo "==> Writing systemd unit ${UNIT_FILE} ..." -cat > "$UNIT_FILE" < "${UNIT_PATH}" < "${INSTALL_DIR}/station.rb" <<'RBRUN' -Dir.chdir(File.expand_path(__dir__)) -exec("liquidsoap", File.expand_path("station.liq")) -RBRUN -chown "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "${INSTALL_DIR}/station.rb" - -chmod o+x "$INSTALL_DIR" - systemctl daemon-reload +echo "==> Installed ${UNIT_PATH}" -# --------------------------------------------------------------------------- -# 11. Cron jobs - installed into the liquidsoap user's crontab so all -# database/media writes happen under the same identity as the service. -# --------------------------------------------------------------------------- -echo "==> Setting up cron jobs under user '${LIQUIDSOAP_USER}' ..." -FETCH_PREFIX="cd ${INSTALL_DIR} && GEM_HOME=${GEMS_DIR} GEM_PATH=${GEMS_DIR} ${JRUBY_BIN} -S fetch_podcasts.rb" -UPDATE_PREFIX="cd ${INSTALL_DIR} && GEM_HOME=${GEMS_DIR} GEM_PATH=${GEMS_DIR} ${JRUBY_BIN} -S update_playlists.rb" -FETCH_CRON="0 * * * * ${FETCH_PREFIX} >> ${STORAGE_PATH}/logs/fetch.log 2>&1" -UPDATE_CRON="30 * * * * ${UPDATE_PREFIX} >> ${STORAGE_PATH}/logs/update.log 2>&1" +# --- 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_UPDATE="30 * * * * cd ${INSTALL_DIR} && ${INSTALL_DIR}/run_radio.sh update_playlist.rb >> ${STORAGE_ROOT}/logs/update_cron.log 2>&1" -sudo -u "$LIQUIDSOAP_USER" crontab -l 2>/dev/null | grep -vF "fetch_podcasts.rb" | grep -vF "update_playlists.rb" > /tmp/cron_ls_rb.$$ || true -echo "$FETCH_CRON" >> /tmp/cron_ls_rb.$$ -echo "$UPDATE_CRON" >> /tmp/cron_ls_rb.$$ -sudo -u "$LIQUIDSOAP_USER" crontab /tmp/cron_ls_rb.$$ -rm -f /tmp/cron_ls_rb.$$ +CURRENT_CRONTAB=$(crontab -u liquidsoap -l 2>/dev/null || true) +NEW_CRONTAB="${CURRENT_CRONTAB}" +if ! echo "${NEW_CRONTAB}" | grep -qF "fetch_podcasts.rb --fetch-all"; then + NEW_CRONTAB="${NEW_CRONTAB}${NEW_CRONTAB:+$'\n'}${CRON_FETCH}" +fi +if ! echo "${NEW_CRONTAB}" | grep -qF "update_playlist.rb"; then + NEW_CRONTAB="${NEW_CRONTAB}${NEW_CRONTAB:+$'\n'}${CRON_UPDATE}" +fi +echo "${NEW_CRONTAB}" | crontab -u liquidsoap - +echo "==> Cron entries installed for user liquidsoap" -crontab -l 2>/dev/null | grep -vF "fetch_podcasts.rb" | grep -vF "update_playlists.rb" > /tmp/cron_root_rb.$$ || true -crontab /tmp/cron_root_rb.$$ -rm -f /tmp/cron_root_rb.$$ +echo +echo "==> Done. Review ${CONFIG_JSON}, then:" +echo " systemctl start ${SERVICE_NAME}" -# --------------------------------------------------------------------------- -# 12. Enable services -# --------------------------------------------------------------------------- -systemctl enable icecast2 "$SERVICE_NAME" -echo "==> Enabling icecast2 and ${SERVICE_NAME} at boot." - -echo "" -echo "============================================================" -echo " Installation complete." -echo "" -echo " Next steps:" -echo " 1. Drop background music into ${STORAGE_PATH}/music/" -echo " 2. Edit ${INSTALL_DIR}/schedule.txt for scheduled shows" -echo " 3. Review ${INSTALL_DIR}/station.liq" -echo " 4. Start: systemctl start icecast2 ${SERVICE_NAME}" -echo "============================================================" diff --git a/install_for_python b/install_for_python index 2d4e725..9c1707f 100755 --- a/install_for_python +++ b/install_for_python @@ -1,270 +1,313 @@ #!/usr/bin/env bash +# install_for_python - Radio automation installer for Linux Mint 22.3 # -# install_for_python - Provision the Python radio automation stack. -# Idempotent: safe to re-run. Derives the service name from this file's -# containing directory basename. Must be run as root. +# Installs the liquidsoap-based radio automation stack: +# - Creates the 'liquidsoap' system user +# - Prompts for storage path, Icecast credentials, gpodder.net credentials +# - Generates config.json from the answers +# - Sets up directory structure under / +# - Ensures SQLite databases exist with correct schema +# - Installs JRuby gems (sequel, jdbc-sqlite3, nokogiri, json) one at a time +# - Writes the self-locating run_radio.sh launcher +# - Installs crontab entries (as liquidsoap) for periodic fetch + update +# - Optionally enables the systemd service # +# Usage: sudo ./install_for_python set -euo pipefail +# --------------------------------------------------------------------------- +# Derive service name from containing directory name +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVICE_NAME="$(basename "$SCRIPT_DIR")" +SYSTEMD_UNIT="/etc/systemd/system/${SERVICE_NAME}.service" + +echo "=== ${SERVICE_NAME} Installer ===" +echo + +# --------------------------------------------------------------------------- +# Root check +# --------------------------------------------------------------------------- if [[ $EUID -ne 0 ]]; then - echo "ERROR: this installer must be run as root." >&2 - exit 1 -fi - -INSTALL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SERVICE_NAME="$(basename "$INSTALL_DIR")" -UNIT_FILE="/etc/systemd/system/${SERVICE_NAME}.service" -LIQUIDSOAP_USER="liquidsoap" - -echo "==> Installing ${SERVICE_NAME} (Python stack) from ${INSTALL_DIR}" - -# --------------------------------------------------------------------------- -# 1. Base packages -# --------------------------------------------------------------------------- -echo "==> Ensuring base packages..." -export DEBIAN_FRONTEND=noninteractive -apt-get update -qq -apt-get install -y -qq \ - python3 python3-pip python3-venv \ - liquidsoap icecast2 jq curl ca-certificates >/dev/null - -# Ensure the dedicated service user exists. -if ! id -u "$LIQUIDSOAP_USER" >/dev/null 2>&1; then - useradd --system --create-home --shell /usr/sbin/nologin "$LIQUIDSOAP_USER" - echo " created system user '${LIQUIDSOAP_USER}'" + echo "ERROR: Please run as root (sudo)." >&2 + exit 1 fi # --------------------------------------------------------------------------- -# 2. Interactive configuration (defaults from existing config.json) +# Create liquidsoap system user if missing # --------------------------------------------------------------------------- -CONFIG_JSON="${INSTALL_DIR}/config.json" - -prompt() { - local prompt_text="$1" default="${2:-}" - local current="" - if [[ -n "$default" ]]; then - read -rp "${prompt_text} [${default}]: " current || true - echo "${current:-$default}" - else - read -rp "${prompt_text}: " current || true - echo "$current" - fi -} - -existing_storage="" ; existing_ic_host="" ; existing_ic_port="" -existing_ic_mount="" ; existing_ic_user="" ; existing_ic_pass="" -existing_gp_enable="" ; existing_gp_host="" ; existing_gp_user="" ; existing_gp_pass="" - -if [[ -f "$CONFIG_JSON" ]]; then - echo "==> Found existing config.json; using it for defaults." - existing_storage=$(jq -r '.storage // empty' "$CONFIG_JSON") - existing_ic_host=$(jq -r '.icecast.host // empty' "$CONFIG_JSON") - existing_ic_port=$(jq -r '.icecast.port // empty' "$CONFIG_JSON") - existing_ic_mount=$(jq -r '.icecast.mount // empty' "$CONFIG_JSON") - existing_ic_user=$(jq -r '.icecast.username // empty' "$CONFIG_JSON") - existing_ic_pass=$(jq -r '.icecast.password // empty' "$CONFIG_JSON") - existing_gp_enable=$(jq -r '.gpodder.enable // empty' "$CONFIG_JSON") - existing_gp_host=$(jq -r '.gpodder.host // empty' "$CONFIG_JSON") - existing_gp_user=$(jq -r '.gpodder.username // empty' "$CONFIG_JSON") - existing_gp_pass=$(jq -r '.gpodder.password // empty' "$CONFIG_JSON") +if ! id -u liquidsoap >/dev/null 2>&1; then + echo "Creating system user 'liquidsoap'..." + useradd --system --create-home --shell /usr/sbin/nologin liquidsoap + echo "Done." +else + echo "User 'liquidsoap' already exists." fi -echo "" -echo "--- Storage ---" -STORAGE_PATH=$(prompt "Storage path (media + state + logs)" "${existing_storage:-/mnt/storage/radio}") -[[ -z "$STORAGE_PATH" ]] && STORAGE_PATH="${existing_storage:-/mnt/storage/radio}" +# --------------------------------------------------------------------------- +# Prompt for configuration values +# --------------------------------------------------------------------------- +echo +read -rp "Station data directory [/mnt/storage/radio]: " STORAGE +STORAGE="${STORAGE:-/mnt/storage/radio}" -echo "" -echo "--- Icecast (source credentials) ---" -IC_HOST=$(prompt "Icecast host" "$existing_ic_host"); IC_HOST=${IC_HOST:-localhost} -IC_PORT=$(prompt "Icecast source port" "$existing_ic_port"); IC_PORT=${IC_PORT:-7777} -IC_MOUNT=$(prompt "Mount point" "$existing_ic_mount"); IC_MOUNT=${IC_MOUNT:-/data} -IC_USER=$(prompt "Source username" "$existing_ic_user"); IC_USER=${IC_USER:-source} -read -rsp "Source password: " IC_PASS || true; echo; IC_PASS=${IC_PASS:-$existing_ic_pass} +read -rp "Icecast host [192.168.0.200]: " ICECAST_HOST +ICECAST_HOST="${ICECAST_HOST:-192.168.0.200}" -echo "" -echo "--- gPodder sync (optional) ---" -GP_ENABLE=$(prompt "Enable gPodder sync? (true/false)" "$existing_gp_enable"); GP_ENABLE=${GP_ENABLE:-false} -GP_HOST=$(prompt "gPodder host" "$existing_gp_host"); GP_HOST=${GP_HOST:-https://gpodder.net} -GP_USER=$(prompt "gPodder username" "$existing_gp_user") -read -rsp "gPodder password: " GP_PASS || true; echo; GP_PASS=${GP_PASS:-$existing_gp_pass} +read -rp "Icecast source port [7777]: " ICECAST_PORT +ICECAST_PORT="${ICECAST_PORT:-7777}" -echo "" -echo "Summary:" -echo " Storage : $STORAGE_PATH" -echo " Icecast : $IC_USER@$IC_HOST:$IC_PORT mount=$IC_MOUNT" -echo " gPodder : enabled=$GP_ENABLE ($GP_USER @ $GP_HOST)" -read -rp "Proceed? [y/N]: " confirm || true -case "$confirm" in - [Yy]*) ;; - *) echo "Aborted."; exit 0 ;; -esac +read -rp "Icecast mount point [/data]: " ICECAST_MOUNT +ICECAST_MOUNT="${ICECAST_MOUNT:-/data}" + +read -rp "Icecast source username [source]: " SOURCE_USER +SOURCE_USER="${SOURCE_USER:-source}" + +read -rsp "Icecast source password: " SOURCE_PASS +echo + +GPODDER_ENABLE="" +until [[ "$GPODDER_ENABLE" =~ ^[YyNn]$ ]]; do + read -rp "Enable gPodder.net sync? [y/N]: " GPODDER_ENABLE +done +GPODDER_ENABLED=false +[[ "$GPODDER_ENABLE" =~ ^[Yy]$ ]] && GPODDER_ENABLED=true + +GPODDER_HOST="gpodder.net" +GPODDER_USER="" +GPODDER_PASS="" +if [[ "$GPODDER_ENABLED" == true ]]; then + read -rp "gPodder.net host [gpodder.net]: " GPODDER_HOST_IN + GPODDER_HOST="${GPODDER_HOST_IN:-gpodder.net}" + read -rp "gPodder.net username: " GPODDER_USER + read -rsp "gPodder.net password: " GPODDER_PASS + echo +fi # --------------------------------------------------------------------------- -# 3. Create the directory tree +# Create directory structure # --------------------------------------------------------------------------- -echo "==> Creating directory tree under $STORAGE_PATH ..." -mkdir -p "$STORAGE_PATH"/{music,podcasts,jingles,announcements,state,playlists,logs} -chown -R "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$STORAGE_PATH" +echo +echo "Creating directory structure under ${STORAGE} ..." +mkdir -p "${STORAGE}"/{state,podcasts,music,jingles,announcements,playlists,logs} +chown -R liquidsoap:liquidsoap "${STORAGE}" +chmod -R u+rwX,g+rwX,o+rX "${STORAGE}" +echo "Done." # --------------------------------------------------------------------------- -# 4. Write config.json (mode 600) +# Generate config.json # --------------------------------------------------------------------------- -echo "==> Writing $CONFIG_JSON ..." -cat > "$CONFIG_JSON" < "$CONFIG_FILE" < Initializing SQLite databases ..." -python3 - "$STORAGE_PATH/state" <<'PYINIT' -import sqlite3, sys, os -state_dir = sys.argv[1] +STATE_DIR="${STORAGE}/state" +SUBS_DB="${STATE_DIR}/subscriptions.db" +PLAYED_DB="${STATE_DIR}/played.db" -subs = os.path.join(state_dir, "subscriptions.db") -played = os.path.join(state_dir, "played.db") +ensure_schema() { + local db="$1" table_sql="$2" index_sql="$3" + sqlite3 "$db" <<<"$table_sql" + [[ -n "$index_sql" ]] && sqlite3 "$db" <<<"$index_sql" + sqlite3 "$db" "PRAGMA journal_mode=WAL;" +} -conn = sqlite3.connect(subs) -conn.executescript(""" -CREATE TABLE IF NOT EXISTS shows ( - slug TEXT PRIMARY KEY, - guid TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - feed_url TEXT NOT NULL UNIQUE, - source TEXT DEFAULT 'manual', - opml_import INTEGER DEFAULT 0, - archived INTEGER DEFAULT 1, - created_at TEXT DEFAULT (datetime('now')) -); -""") -conn.commit(); conn.close() +SHOWS_SQL='CREATE TABLE IF NOT EXISTS shows ( + slug TEXT PRIMARY KEY, + guid TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + feed_url TEXT NOT NULL UNIQUE, + source TEXT DEFAULT '"'"'manual'"'"', + opml_import INTEGER DEFAULT 0, + archived INTEGER DEFAULT 1, + media_class TEXT, + created_at TEXT +);' -conn = sqlite3.connect(played) -conn.executescript(""" -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, - enclosure_url TEXT, - runlength INTEGER, - played INTEGER DEFAULT 0, - played_at TEXT, - UNIQUE(show_slug, guid) -); -CREATE INDEX IF NOT EXISTS idx_episodes_show_played - ON episodes (show_slug, played); -""") -conn.commit(); conn.close() +EPISODES_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, + enclosure_url TEXT, + runlength INTEGER, + played INTEGER DEFAULT 0, + played_at TEXT, + UNIQUE (show_slug, guid) +);' -print(" subscriptions.db and played.db initialized.") -PYINIT -chown -R "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$STORAGE_PATH/state" +INDEX_EPISODES_SQL='CREATE INDEX IF NOT EXISTS idx_episodes_show_played ON episodes (show_slug, played);' + +echo +echo "Initializing SQLite databases..." +ensure_schema "$SUBS_DB" "$SHOWS_SQL" "" +ensure_schema "$PLAYED_DB" "$EPISODES_SQL" "$INDEX_EPISODES_SQL" +chown liquidsoap:liquidsoap "$SUBS_DB" "$PLAYED_DB" +echo "Done." # --------------------------------------------------------------------------- -# 6. Python virtualenv + dependencies +# Install JRuby runtime dependencies (one gem per command to bound memory) # --------------------------------------------------------------------------- -VENV="${INSTALL_DIR}/.venv" -if [[ ! -x "${VENV}/bin/python" ]]; then - echo "==> Creating Python venv at ${VENV} ..." - python3 -m venv "$VENV" +JRUBY_BIN="/opt/jruby/bin/jruby" +GEM_BIN="/opt/jruby/bin/gem" +GEMS_HOME="${SCRIPT_DIR}/.gems" + +if [[ -x "$JRUBY_BIN" ]]; then + echo + echo "Installing JRuby gem dependencies into ${GEMS_HOME} ..." + mkdir -p "$GEMS_HOME" + chown liquidsoap:liquidsoap "$GEMS_HOME" + + install_gem() { + local gem_name="$1" + shift + local extra_args=("$@") + echo " -> gem install ${gem_name} ${extra_args[*]}" + sudo -u liquidsoap bash -c " + export GEM_HOME=${GEMS_HOME} + export GEM_PATH=${GEMS_HOME}:/opt/jruby/lib/ruby/gems/shared + ${GEM_BIN} install ${gem_name} ${extra_args[*]} --no-document + " || echo " WARNING: failed to install ${gem_name}" + } + + # Core persistence + install_gem "sequel" + install_gem "jdbc-sqlite3" + + # XML parsing. Pure-Java build under JRuby (--platform java) bundles + # Xerces/NekoHTML/Xalan as JARs; no libxml2-dev or compiler required. + install_gem "nokogiri" "--platform" "java" + + # Convenience JSON (usually stdlib, but ensure availability) + install_gem "json" + + echo "JRUBY DEPENDENCIES COMPLETE" +else + echo + echo "WARNING: ${JRUBY_BIN} not found; skipping JRuby gem installation." + echo " Install JRuby manually, then re-run this installer or run:" + echo " sudo -u liquidsoap ${GEM_BIN} install sequel jdbc-sqlite3 json" + echo " sudo -u liquidsoap ${GEM_BIN} install nokogiri --platform java" fi -echo "==> Installing Python dependencies (feedparser, requests) ..." -"${VENV}/bin/pip" install --quiet --upgrade pip -"${VENV}/bin/pip" install --quiet feedparser requests - -# Make sure the liquidsoap user can traverse the install dir and use the venv. -chmod o+x "$INSTALL_DIR" -chown -R "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$VENV" # --------------------------------------------------------------------------- -# 7. systemd unit (named after the directory) +# Self-locating launcher (sets GEM_HOME/GEM_PATH before JRuby boots) # --------------------------------------------------------------------------- -echo "==> Writing systemd unit ${UNIT_FILE} ..." -cat > "$UNIT_FILE" < "$LAUNCHER" <<'LAUNCH_EOF' +#!/usr/bin/env bash +# run_radio.sh - self-locating JRuby launcher for the radio automation scripts. +# Exports GEM_HOME/GEM_PATH BEFORE jruby boots, because mutating them inside a +# running JRuby process does not reliably affect gem resolution (JRuby #5269). +# +# Usage: ./run_radio.sh [args...] +set -euo pipefail + +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GEMS_HOME="${SELF_DIR}/.gems" +JRUBY_HOME="/opt/jruby" + +export GEM_HOME="${GEMS_HOME}" +export GEM_PATH="${GEMS_HOME}:${JRUBY_HOME}/lib/ruby/gems/shared" + +exec "${JRUBY_HOME}/bin/jruby" "${SELF_DIR}/$@" +LAUNCH_EOF +chmod +x "$LAUNCHER" +chown liquidsoap:liquidsoap "$LAUNCHER" +echo "Wrote ${LAUNCHER}" + +# Make the Ruby scripts executable +for rb in "${SCRIPT_DIR}"/*.rb; do + [[ -f "$rb" ]] && chmod +x "$rb" && chown liquidsoap:liquidsoap "$rb" +done + +# --------------------------------------------------------------------------- +# Crontab entries (installed into liquidsoap's crontab, not root's) +# --------------------------------------------------------------------------- +CRON_FETCH="*/30 * * * * cd ${SCRIPT_DIR} && ./run_radio.sh fetch_podcasts.rb >> ${STORAGE}/logs/cron_fetch.log 2>&1" +CRON_UPDATE="15 * * * * cd ${SCRIPT_DIR} && ./run_radio.sh update_playlists.rb >> ${STORAGE}/logs/cron_update.log 2>&1" + +echo +echo "Installing crontab entries for user 'liquidsoap'..." +( crontab -l -u liquidsoap 2>/dev/null || true ) | grep -vF "run_radio.sh" | \ + { cat; echo "$CRON_FETCH"; echo "$CRON_UPDATE"; } | crontab -u liquidsoap - +echo "Crontab updated." + +# --------------------------------------------------------------------------- +# Optional systemd service +# --------------------------------------------------------------------------- +ENABLE_SERVICE="" +until [[ "$ENABLE_SERVICE" =~ ^[YyNn]$ ]]; do + read -rp "Enable ${SERVICE_NAME} systemd service now? [y/N]: " ENABLE_SERVICE +done + +if [[ "$ENABLE_SERVICE" =~ ^[Yy]$ ]]; then + echo + echo "Writing ${SYSTEMD_UNIT} ..." + cat > "$SYSTEMD_UNIT" < "${INSTALL_DIR}/station_runner.py" <<'PYRUN' -import os, subprocess, sys -install_dir = os.path.dirname(os.path.abspath(__file__)) -os.chdir(install_dir) -sys.exit(subprocess.call(["liquidsoap", os.path.join(install_dir, "station.liq")])) -PYRUN -chown "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "${INSTALL_DIR}/station_runner.py" - -systemctl daemon-reload + systemctl daemon-reload + systemctl enable --now "${SERVICE_NAME}" + echo "Service ${SERVICE_NAME} enabled and started." +else + echo "Skipping systemd service (you can enable it later with:" + echo " sudo cp ${SCRIPT_DIR}/${SERVICE_NAME}.service /etc/systemd/system/ && sudo systemctl enable --now ${SERVICE_NAME})" +fi # --------------------------------------------------------------------------- -# 8. Cron jobs — installed into the liquidsoap user's crontab so all -# database/media writes happen under the same identity as the service. +# Summary # --------------------------------------------------------------------------- -echo "==> Setting up cron jobs under user '${LIQUIDSOAP_USER}' ..." -FETCH_CRON="0 * * * * cd ${INSTALL_DIR} && ./.venv/bin/python fetch_podcasts.py >> ${STORAGE_PATH}/logs/fetch.log 2>&1" -UPDATE_CRON="30 * * * * cd ${INSTALL_DIR} && ./.venv/bin/python update_playlists.py >> ${STORAGE_PATH}/logs/update.log 2>&1" - -# Remove any stale entries from the liquidsoap user's crontab, then add ours. -sudo -u "$LIQUIDSOAP_USER" crontab -l 2>/dev/null | grep -vF "fetch_podcasts.py" | grep -vF "update_playlists.py" > /tmp/cron_ls_py.$$ || true -echo "$FETCH_CRON" >> /tmp/cron_ls_py.$$ -echo "$UPDATE_CRON" >> /tmp/cron_ls_py.$$ -sudo -u "$LIQUIDSOAP_USER" crontab /tmp/cron_ls_py.$$ -rm -f /tmp/cron_ls_py.$$ - -# Also scrub these from root's crontab in case an earlier install put them there. -crontab -l 2>/dev/null | grep -vF "fetch_podcasts.py" | grep -vF "update_playlists.py" > /tmp/cron_root_py.$$ || true -crontab /tmp/cron_root_py.$$ -rm -f /tmp/cron_root_py.$$ - -# --------------------------------------------------------------------------- -# 9. Enable services -# --------------------------------------------------------------------------- -systemctl enable icecast2 "$SERVICE_NAME" -echo "==> Enabling icecast2 and ${SERVICE_NAME} at boot." - -echo "" -echo "============================================================" +echo +echo "==============================================" echo " Installation complete." -echo "" -echo " Next steps:" -echo " 1. Drop background music into ${STORAGE_PATH}/music/" -echo " 2. Edit ${INSTALL_DIR}/schedule.txt for scheduled shows" -echo " 3. Review ${INSTALL_DIR}/station.liq" -echo " 4. Start: systemctl start icecast2 ${SERVICE_NAME}" -echo "============================================================" +echo "----------------------------------------------" +echo " Storage: ${STORAGE}" +echo " Config: ${CONFIG_FILE}" +echo " Launcher: ${LAUNCHER}" +echo " Service: ${SERVICE_NAME}" +echo "----------------------------------------------" +echo " Quick start:" +echo " cd ${SCRIPT_DIR}" +echo " sudo -u liquidsoap ./run_radio.sh fetch_podcasts.rb --list-shows" +echo " sudo -u liquidsoap ./run_radio.sh fetch_podcasts.rb --add-show " +echo " sudo -u liquidsoap ./run_radio.sh update_playlists.rb" +echo "==============================================" diff --git a/run_jruby b/run_jruby index 4b89d24..0f913e4 100755 --- a/run_jruby +++ b/run_jruby @@ -3,5 +3,5 @@ # them up natively at boot (in-process mutation is unreliable per JRuby #5269). SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" export GEM_HOME="${GEM_HOME:-$SCRIPT_DIR/.gems}" -export GEM_PATH="${GEM_PATH:-$SCRIPT_DIR/.gems:/opt/jruby/lib/ruby/gems/shared}" -exec /opt/jruby/bin/jruby "$@" +export GEM_PATH="${GEM_PATH:-$SCRIPT_DIR/.gems}" +exec /usr/bin/jruby "$@" diff --git a/run_radio.sh b/run_radio.sh index 32a4da9..d0ab742 100755 --- a/run_radio.sh +++ b/run_radio.sh @@ -1,28 +1,33 @@ -#!/usr/bin/env bash -# run_radio.sh - Launcher for the radio automation JRuby scripts. +#!/bin/bash +# run_radio.sh - Self-locating launcher for the JRuby radio automation scripts. # -# Sets GEM_HOME/GEM_PATH in the environment BEFORE jruby boots, because -# mutating those variables inside a running JRuby process does not reliably -# affect gem resolution (JRuby issue #5269). Setting them pre-boot lets -# RubyGems pick them up natively. Self-locating, so the whole tree can be -# relocated without editing anything. +# Why a launcher: mutating GEM_HOME/GEM_PATH inside a running JRuby process does +# not reliably affect gem resolution (JRuby #5269). Exporting them BEFORE jruby +# boots is the reliable path. This script derives its own location, points the +# gem env at the co-located .gems dir, passes a bounded JVM heap, and execs +# jruby with the requested script + args. The .rb scripts keep a portable +# "#!/usr/bin/env jruby" shebang and are invoked THROUGH this launcher. # -# Usage: ./run_radio.sh [args...] -# e.g. ./run_radio.sh fetch_podcasts.rb --list-shows - +# Heap sizing: feed parsing is streamed to disk (constant memory), but episode +# downloads buffer per-file, so we give the JVM a comfortable-but-capped ceiling. +# NOTE: this JRuby build ignores JRUBY_OPTS, so JVM flags must be passed on the +# command line via -J (e.g. -J-Xmx2g), not through an environment variable. +# +# Usage: +# ./run_radio.sh [args...] +# sudo -u liquidsoap ./run_radio.sh fetch_podcasts.rb --add-show set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" GEMS_DIR="${SCRIPT_DIR}/.gems" +JRUBY_BIN="/usr/bin/jruby" -export GEM_HOME="${GEM_HOME:-${GEMS_DIR}}" -# Prepend our gems dir; keep JRuby's shared path so stdlib stays reachable. -_JRUBY_SHARED="/opt/jruby/lib/ruby/gems/shared" -if [[ -n "${GEM_PATH:-}" ]]; then - export GEM_PATH="${GEMS_DIR}:${GEM_PATH}" -else - export GEM_PATH="${GEMS_DIR}:${_JRUBY_SHARED}" +export GEM_HOME="${GEMS_DIR}" +export GEM_PATH="${GEMS_DIR}" + +if [[ $# -lt 1 ]]; then + echo "Usage: $(basename "$0") [args...]" >&2 + exit 1 fi -exec /opt/jruby/bin/jruby "$@" - +exec "${JRUBY_BIN}" -J-Xms256m -J-Xmx2g "${SCRIPT_DIR}/$@" diff --git a/station.rb b/station.rb old mode 100644 new mode 100755 diff --git a/update_playlists.rb b/update_playlists.rb index 16ab6f1..adeb538 100755 --- a/update_playlists.rb +++ b/update_playlists.rb @@ -1,265 +1,218 @@ #!/usr/bin/env jruby # frozen_string_literal: true # -# update_playlists.rb - Select the next unplayed episode per show and write an -# annotated URI queue file for station.liq to consume (JRuby/Sequel variant). +# update_playlists.rb - Select the next unplayed episode per show and write +# annotated-URI queue files for station.liq to consume. # -# Shares the same exclusive lockfile as fetch_podcasts.rb; skips cleanly if -# the fetcher holds it. Databases run in WAL mode with a busy timeout. +# Reads from played.db (episode records with played flag), writes queue files +# under /playlists/. Cycles archived shows when all episodes are +# already played. +# +# Usage: +# ./update_playlists.rb [--json] +# +# Options: +# --json Emit a JSON summary of each show's episode counts to stdout. -# --------------------------------------------------------------------------- -# Gem path bootstrap: pin GEM_HOME/GEM_PATH before any require so the script -# finds its gems regardless of how it was launched (sudo strips them by -# default via env_reset). Derived from this file's own location so the -# scripts are relocatable. We PREPEND our .gems dir to the existing GEM_PATH -# rather than replacing it, so JRuby's shared/stdlib path stays reachable. -# Guards prevent overriding an explicit environment. -# --------------------------------------------------------------------------- -SCRIPT_DIR = File.expand_path(File.dirname(__FILE__)) -GEMS_DIR = File.join(SCRIPT_DIR, ".gems") -ENV["GEM_HOME"] = GEMS_DIR unless ENV["GEM_HOME"] -_existing_gem_path = ENV["GEM_PATH"].to_s.split(":").reject(&:empty?) -ENV["GEM_PATH"] = ([GEMS_DIR] + _existing_gem_path).uniq.join(":") -Gem.paths = { "GEM_HOME" => ENV["GEM_HOME"], "GEM_PATH" => ENV["GEM_PATH"] } - -require "sequel" require "json" -require "logger" -require "time" +require "sequel" -ROOT = SCRIPT_DIR -CONFIG_PATH = File.join(ROOT, "config.json") - -$state_dir = nil -$subs_db_path = nil -$played_db_path = nil -$podcasts_dir = nil -$logs_dir = nil -$playlists_dir = nil -$lock_file = nil +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)) + raw = File.read(CONFIG_PATH) + cfg = JSON.parse(raw) + raise "FATAL: storage missing from config.json" unless cfg["storage"] && !cfg["storage"].to_s.empty? + cfg end -def init_paths! - cfg = load_config - storage = File.realpath(cfg["storage"]) - $state_dir = File.join(storage, "state") - $subs_db_path = File.join($state_dir, "subscriptions.db") - $played_db_path = File.join($state_dir, "played.db") - $podcasts_dir = File.join(storage, "podcasts") - $logs_dir = File.join(storage, "logs") - $playlists_dir = File.join(storage, "playlists") - $lock_file = File.join($state_dir, "radio.lock") +CFG = load_config +STORAGE_ROOT = CFG["storage"] +STATE_DIR = File.join(STORAGE_ROOT, "state") +PLAYLISTS_DIR = File.join(STORAGE_ROOT, "playlists") +LOG_DIR = File.join(STATE_DIR, "logs") +LOCK_FILE = File.join(STATE_DIR, "radio.lock") +SUBS_DB_PATH = File.join(STATE_DIR, "subscriptions.db") +EPISODES_DB_PATH = File.join(STATE_DIR, "episodes.db") + +[DIRS_TO_CREATE].each do |d| + Dir.mkdir(d) unless Dir.exist?(d) end -$log = Logger.new(STDOUT) -$log.formatter = proc { |msg, _severity, _time, _progname| "#{Time.now} [INFO] #{msg}\n" } - -def log_error(msg) - $log.error(msg) +$log_fh = File.open(File.join(LOG_DIR, "update_playlists.log"), "a") +def log(level, msg) + ts = Time.now.strftime("%Y-%m-%d %H:%M:%S") + line = "[#{ts}] [#{level}] #{msg}" + puts(line) + $log_fh.write("#{line}\n") + $log_fh.flush end -def setup_logging! - $log.instance_variable_set(:@logdev, - Logger::LogDevice.new([STDOUT, File.join($logs_dir, "update.log")])) +db_subs = Sequel.jdbc("sqlite:", SUBS_DB_PATH) +db_eps = Sequel.jdbc("sqlite:", EPISODES_DB_PATH) + +db_subs.execute("PRAGMA journal_mode=WAL;") +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 -# --------------------------------------------------------------------------- -# Schema: single source of truth, applied idempotently via raw SQL through -# Database#execute. Raw DDL avoids the Sequel create_table/alter_table DSL, -# which is unreliable under JRuby (instance_exec'd generator methods can -# resolve to nil). Works identically on CRuby and JDBC. -# --------------------------------------------------------------------------- -SHOWS_SQL = <<~SQL.freeze - CREATE TABLE IF NOT EXISTS shows ( - slug TEXT PRIMARY KEY, - guid TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - feed_url TEXT NOT NULL UNIQUE, - source TEXT DEFAULT 'manual', - opml_import INTEGER DEFAULT 0, - archived INTEGER DEFAULT 1, - media_class TEXT, - created_at TEXT - ); -SQL +def ensure_schema(db, path, label) + if !table_exists?(db, "shows") + db.execute <<-SQL +CREATE TABLE IF NOT EXISTS shows ( + guid TEXT PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + feed_url TEXT NOT NULL, + audio_only INTEGER DEFAULT 1, + archive INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) + SQL + log("INFO", "Created 'shows' table in #{label}") + end -EPISODES_SQL = <<~SQL.freeze - 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, - enclosure_url TEXT, - runlength INTEGER, - played INTEGER DEFAULT 0, - played_at TEXT, - UNIQUE (show_slug, guid) - ); -SQL - -INDEX_EPISODES_SQL = <<~SQL.freeze - CREATE INDEX IF NOT EXISTS idx_episodes_show_played ON episodes (show_slug, played); -SQL - -def tune(db) - # Plain-SQL pragmas via Database#execute, which works on CRuby and JRuby/JDBC - # across all modern Sequel versions (the :pragma extension is CRuby-only and - # db.sql requires Sequel >= 5.42). - db.execute("PRAGMA journal_mode=WAL;") - db.execute("PRAGMA busy_timeout=5000;") + if !table_exists?(db, "episodes") + db.execute <<-SQL +CREATE TABLE IF NOT EXISTS episodes ( + guid TEXT PRIMARY KEY, + show_guid TEXT NOT NULL REFERENCES shows(guid), + title TEXT, + enclosure_url TEXT, + runlength INTEGER DEFAULT 0, + played INTEGER DEFAULT 0, + downloaded INTEGER DEFAULT 0, + local_path TEXT, + fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) + SQL + log("INFO", "Created 'episodes' table in #{label}") + end end -def connect_subs - db = Sequel.connect("jdbc:sqlite:#{$subs_db_path}") - tune(db) - db.execute(SHOWS_SQL) - db -end - -def connect_played - db = Sequel.connect("jdbc:sqlite:#{$played_db_path}") - tune(db) - db.execute(EPISODES_SQL) - db.execute(INDEX_EPISODES_SQL) - db -end - -# --------------------------------------------------------------------------- -# Mutual exclusion via flock on a shared lockfile. -# --------------------------------------------------------------------------- -$lock_fh = nil +ensure_schema(db_subs, SUBS_DB_PATH, "subscriptions.db") +ensure_schema(db_eps, EPISODES_DB_PATH, "episodes.db") def acquire_lock! - Dir.mkdir($state_dir) unless Dir.exist?($state_dir) - fh = File.open($lock_file, File::RDWR | File::CREAT, 0o644) + @lock_fh = File.new(LOCK_FILE, "a+") begin - fh.flock(File::LOCK_EX | File::LOCK_NB) - rescue Errno::EACCES, Errno::EAGAIN - fh.close - return false + @lock_fh.flock(File::LOCK_EX | File::LOCK_NB) + rescue IOError + log("WARN", "Another radio process holds the lock; skipping this run.") + exit 0 end - fh.truncate(0) - fh.write(Process.pid.to_s) - fh.rewind - $lock_fh = fh - true end def release_lock! - return if $lock_fh.nil? - begin - $lock_fh.flock(File::LOCK_UN) - $lock_fh.close - ensure - $lock_fh = nil + @lock_fh.flock(File::LOCK_UN) if @lock_fh + @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 -# --------------------------------------------------------------------------- -# Queue-file generation -# --------------------------------------------------------------------------- -def annotate_uri(runlength, title, uri) - def esc(v) - '"' + v.to_s.gsub('"', '\\"') + '"' +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 - "annotate:liq_runlength=#{esc(runlength)},liq_title=#{esc(title)}:" + uri -end - -def select_next_episode(slug, played_db) - played_db[:episodes] - .where(show_slug: slug, played: 0) - .order(:id.asc) - .limit(1) - .first -end - -def mark_as_played(slug, guid, played_db) - played_db[:episodes] - .where(show_slug: slug, guid: guid) - .update(played: 1, played_at: Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")) -end - -def write_queue_file(slug, ep, archived) - uri = archived ? ep[:file_path] : ep[:enclosure_url] - return nil if uri.nil? || uri.empty? - line = annotate_uri(ep[:runlength], ep[:title], uri) - out = File.join($playlists_dir, "#{slug}.txt") - File.write(out, line + "\n") - out + + 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 - subs_db = connect_subs - played_db = connect_played - shows = subs_db[:shows].order(:name).all - updated = 0 + shows = db_subs[:shows].all + + if shows.empty? + log("INFO", "No shows registered; nothing to do.") + return + end + + queued_count = 0 + failed_count = 0 + shows.each do |show| - slug = show[:slug] - archived = show[:archived] == 1 - ep = select_next_episode(slug, played_db) - next if ep.nil? - out = write_queue_file(slug, ep, archived) - if out.nil? - $log.warn("No playable URI for #{show[:name]} (#{slug}); skipping.") - next - end - mark_as_played(slug, ep[:guid], played_db) - updated += 1 - $log.info("Queued #{slug}: #{ep[:title]} -> #{File.basename(out)}") - end - subs_db.disconnect - played_db.disconnect - $log.info("=== Update complete: #{updated} show(s) queued ===") -end - -def json_summary - subs_db = connect_subs - played_db = connect_played - shows = subs_db[:shows].order(:name).all - result = {} - shows.each do |show| - slug = show[:slug] - total = played_db[:episodes].where(show_slug: slug).count - unplayed = played_db[:episodes].where(show_slug: slug, played: 0).count - result[slug] = { - name: show[:name], - total: total, - unplayed: unplayed, - played: total - unplayed - } - end - subs_db.disconnect - played_db.disconnect - puts JSON.pretty_generate(result) -end - -def main - args = ARGV.dup - json_mode = args.delete("--json") - - init_paths! - [$state_dir, $logs_dir, $playlists_dir].each do |dir| - Dir.mkdir(dir) unless Dir.exist?(dir) - end - setup_logging! - - if json_mode - json_summary - else - if !acquire_lock! - $log.info("Another radio process holds the lock; skipping this run.") - return - end begin - update_all - ensure - release_lock! + if update_show_queue(show) + queued_count += 1 + 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| + total = db_eps[:episodes].where(show_guid: show[:guid]).count + played = db_eps[:episodes].where(show_guid: show[:guid], played: 1).count + unplayed = total - played + summary[show[:slug]] = { total: total, played: played, unplayed: unplayed } + end + puts(JSON.pretty_generate(summary)) + end end -main if __FILE__ == $PROGRAM_NAME +begin + acquire_lock! + begin + update_all + ensure + release_lock! + end +rescue Exception => e + log("ERROR", "Fatal error: #{e.class} #{e.message}") + log("ERROR", e.backtrace.first(10).join("\n")) + exit 1 +end + +db_subs.disconnect +db_eps.disconnect +$log_fh.close +