#!/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). # # 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. # --------------------------------------------------------------------------- # 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" 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 def load_config JSON.parse(File.read(CONFIG_PATH)) 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 $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, "update.log")])) 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 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;") 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 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 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 end end # --------------------------------------------------------------------------- # Queue-file generation # --------------------------------------------------------------------------- def annotate_uri(runlength, title, uri) def esc(v) '"' + v.to_s.gsub('"', '\\"') + '"' 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 end def update_all subs_db = connect_subs played_db = connect_played shows = subs_db[:shows].order(:name).all updated = 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! end end end main if __FILE__ == $PROGRAM_NAME