From dc53dd8673bd165cb6970898ed5d0941bcec212b Mon Sep 17 00:00:00 2001 From: "G. Gibson" Date: Thu, 17 Sep 2026 14:49:12 -0700 Subject: [PATCH] bug fixes - update_playlists.rb --- update_playlists.rb | 402 +++++++++++++++++++++++++------------------- 1 file changed, 229 insertions(+), 173 deletions(-) diff --git a/update_playlists.rb b/update_playlists.rb index 464c09c..66114d1 100755 --- a/update_playlists.rb +++ b/update_playlists.rb @@ -1,203 +1,259 @@ #!/usr/bin/env jruby # frozen_string_literal: true -# -# update_playlists.rb - Select next unplayed episode per show and write queue files -# -# For each show in subscriptions.db: -# 1. Count total/unplayed episodes in played.db -# 2. If all played (and archive=0): reset all to played=0, pick first, mark played=1 -# 3. If unplayed > 0: pick one at random, mark played=1 -# 4. Write /queue/.txt with annotated URI or "SKIP" -# -# Usage: -# ./update_playlists.rb [--json] -require "sequel" -require "jdbc/sqlite3" -require "digest/sha1" -require "fileutils" -require "json" +require 'sequel' +require 'json' +require 'digest/sha1' SCRIPT_DIR = File.expand_path(File.dirname(__FILE__)) CONFIG_PATH = File.join(SCRIPT_DIR, "config.json") -def load_config - raw = File.read(CONFIG_PATH) - cfg = JSON.parse(raw) - storage = cfg["storage"].to_s.strip - raise "Missing 'storage' in config.json" if storage.empty? - { - storage: storage, - icecast_port: cfg.dig("icecast", "port").to_i, - mount_point: cfg.dig("icecast", "mount_point").to_s, - source_user: cfg.dig("icecast", "source_username").to_s, - source_pass: cfg.dig("icecast", "source_password").to_s, - gpodder_host: cfg.dig("gpodder", "host").to_s, - gpodder_user: cfg.dig("gpodder", "username").to_s, - gpodder_pass: cfg.dig("gpodder", "password").to_s, - gpodder_device: cfg.dig("gpodder", "device_id").to_s, - gpodder_enable: cfg.dig("gpodder", "enable") == true - } -end +CFG = JSON.parse(File.read(CONFIG_PATH)) rescue {} +STORAGE = CFG["storage"] || "/mnt/storage/radio" +STATE_DIR = File.join(STORAGE, "state") +LOG_DIR = File.join(STORAGE, "logs") -CFG = load_config -STATE_DIR = File.join(CFG[:storage], "state") -QUEUE_DIR = File.join(CFG[:storage], "queue") -LOG_DIR = File.join(CFG[:storage], "logs") SUBS_DB = File.join(STATE_DIR, "subscriptions.db") PLAYED_DB = File.join(STATE_DIR, "played.db") -MEDIA_DIRS = %w[music podcasts jingles announcements] -MEDIA_DIRS.each do |d| - FileUtils.mkdir_p(File.join(CFG[:storage], d)) -end -FileUtils.mkdir_p(STATE_DIR) -FileUtils.mkdir_p(QUEUE_DIR) -FileUtils.mkdir_p(LOG_DIR) +QUEUE_DIR = File.join(STORAGE, "queue") +SCHEDULE_FILE = File.join(SCRIPT_DIR, "schedule.txt") -LOG_FILE = File.join(LOG_DIR, "update_playlists.log") -$log_fh = File.open(LOG_FILE, "a+") +$db_s = Sequel.connect("jdbc:sqlite:" + SUBS_DB) +$db_p = Sequel.connect("jdbc:sqlite:" + PLAYED_DB) -def log(level, msg) - ts = Time.now.strftime("%Y-%m-%d %H:%M:%S") - line = "[#{ts}] [#{level}] #{msg}" - $stdout.puts(line) - $log_fh.write(line + "\n") - $log_fh.flush -rescue Exception => e - # Log file may be unavailable; fall back to stdout only - $stdout.puts("[WARN] Could not write log: #{e.message}") +def log(msg) + puts "[INFO] #{msg}" end -def table_exists?(db, tbl) - db[:sqlite_master].where(type: "table", name: tbl).count > 0 -rescue Exception => e - false +def ensure_dirs + [STATE_DIR, LOG_DIR, QUEUE_DIR].each do |d| + Dir.mkdir(d) unless Dir.exist?(d) + end end -def ensure_schema(db_subs, db_eps) - unless table_exists?(db_subs, :shows) - db_subs.execute <<-SQL - CREATE TABLE IF NOT EXISTS shows ( +# def table_exists?(db, tbl) +# db[:sqlite_master].where(type: 'table', name: tbl).count > 0 +# end + +# def table_exists?(db, tbl) +# db[:sqlite_master].where("type = 'table' AND name = ?", tbl.to_s).count > 0 +# end +# def table_exists?(db, tbl) +# result = db.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", [tbl.to_s]).first +# result && result.values.first.to_s.to_i > 0 +# end +# def table_exists?(db, tbl) +# db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", [tbl.to_s]).fetch.first && true || false +# end +# def table_exists?(db, table_name) +# result = db.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='#{table_name.to_s}'") +# count = result.first[0] rescue 0 +# count > 0 +# end + +def table_exists?(db, table_name) + result = db.fetch("SELECT COUNT(*) as cnt FROM sqlite_master WHERE type='table' AND name='#{table_name.to_s}'").first + result && result[:cnt].to_i > 0 +end + + + +def ensure_schema + $db_s.execute("PRAGMA journal_mode=WAL") + $db_s.execute("PRAGMA busy_timeout=5000") + $db_p.execute("PRAGMA journal_mode=WAL") + $db_p.execute("PRAGMA busy_timeout=5000") + + unless table_exists?($db_s, :shows) + begin + $db_s.execute(%Q{ + CREATE TABLE shows ( guid TEXT PRIMARY KEY, slug TEXT UNIQUE NOT NULL, title TEXT NOT NULL, feed_url TEXT NOT NULL, - description TEXT DEFAULT '', - category TEXT DEFAULT '', - audio_only INTEGER DEFAULT 1, archive INTEGER DEFAULT 0, + opml_import INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - SQL - end - unless table_exists?(db_eps, :episodes) - db_eps.execute <<-SQL - CREATE TABLE IF NOT EXISTS episodes ( - guid TEXT PRIMARY KEY, - show_guid TEXT NOT NULL, - title TEXT NOT NULL, - enclosure_url TEXT NOT NULL, - enclosure_type TEXT DEFAULT '', - duration_sec INTEGER DEFAULT 0, - pub_date TEXT DEFAULT '', - local_path TEXT DEFAULT '', - played INTEGER DEFAULT 0, - downloaded INTEGER DEFAULT 0, - fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - SQL - end -end - -$stdout.sync = true - -begin - db_subs = Sequel.connect("jdbc:sqlite:" + SUBS_DB) - db_eps = Sequel.connect("jdbc:sqlite:" + PLAYED_DB) -ensure_schema(db_subs, db_eps) - - json_output = ARGV.include?("--json") - results = [] - - shows = db_subs[:shows].all - if shows.empty? - log("INFO", "No shows registered.") - else - shows.each do |show| - slug = show[:slug] - show_g = show[:guid] - archive = show[:archive] || 0 - - total = db_eps[:episodes].where(show_guid: show_g).count - unplayed = db_eps[:episodes].where(show_guid: show_g, played: 0).count - - chosen = nil - - if total == 0 - log("INFO", "#{slug}: no episodes yet, SKIP") - elsif unplayed == 0 && archive == 0 - # Cycle: reset all to unplayed, pick first, mark played - db_eps[:episodes].where(show_guid: show_g).update(played: 0) - ep = db_eps[:episodes].where(show_guid: show_g).order(:pub_date.asc).first - if ep - db_eps[:episodes].where(guid: ep[:guid]).update(played: 1) - chosen = ep - log("INFO", "#{slug}: cycled, picked '#{ep[:title]}'") - end - elsif unplayed > 0 - eps = db_eps[:episodes].where(show_guid: show_g, played: 0).all - ep = eps.sample - db_eps[:episodes].where(guid: ep[:guid]).update(played: 1) - chosen = ep - log("INFO", "#{slug}: picked '#{ep[:title]}' (#{unplayed} unplayed)") - end - - qf = File.join(QUEUE_DIR, "#{slug}.txt") - if chosen - dur = chosen[:duration_sec].to_i - annot = "annotate:liq_runlength=\"#{dur}\",liq_title=\"#{chosen[:title]}\"" - uri = chosen[:enclosure_url] - File.write(qf, "#{annot}:#{uri}\n") - else - File.write(qf, "SKIP\n") - end - - results << { - "slug" => slug, - "total" => total, - "unplayed" => unplayed, - "picked" => chosen ? chosen[:title] : nil - } + ); + }) + rescue Exception => the_error end end - if json_output - puts JSON.pretty_generate(results) + unless table_exists?($db_p, :episodes) + $db_p.execute(%Q{ + CREATE TABLE episodes ( + guid TEXT PRIMARY KEY, + show_guid TEXT NOT NULL, + title TEXT, + url TEXT, + duration_seconds REAL DEFAULT 0, + played INTEGER DEFAULT 0, + local_path TEXT, + file_size_bytes INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + }) end - - log("INFO", "Done. Processed #{results.size} shows.") - - begin - db_subs.disconnect - rescue Exception => e - log("WARN", "Error disconnecting subs: #{e.message}") - end - begin - db_eps.disconnect - rescue Exception => e - log("WARN", "Error disconnecting eps: #{e.message}") - end - $log_fh.close - -rescue Interrupt - log("INFO", "Interrupted.") - exit 1 -rescue Exception => e - log("ERROR", "#{e.class}: #{e.message}") - log("ERROR", e.backtrace.first(5).join("\n")) - exit 1 end -main if __FILE__ == $PROGRAM_NAME +def load_schedule + return [] unless File.exist?(SCHEDULE_FILE) + lines = File.readlines(SCHEDULE_FILE) + entries = [] + lines.each do |raw_line| + line = raw_line.strip + next if line.empty? || line.start_with?('#') + parts = line.split(/\s+/) + min, hour, dom, mon, dow, type, target = parts[0..6] + runlength = parts[7] ? parts[7].to_i : nil + entries << {min: min, hour: hour, dom: dom, mon: mon, dow: dow, type: type, target: target, runlength: runlength} + end + entries +end +def find_next_episode(show_guid) + row = $db_p[:episodes].where(show_guid: show_guid, played: 0).order(:created_at).first + row +end + +def mark_played(ep_guid) + $db_p[:episodes].where(guid: ep_guid).update(played: 1) +end + +def reset_show_episodes(show_guid) + $db_p[:episodes].where(show_guid: show_guid).update(played: 0) +end + +def write_queue_file(filename, uris) + path = File.join(QUEUE_DIR, filename) + File.open(path, 'w') do |f| + uris.each do |uri| + f.puts(uri) + end + end + log("Queue written: #{path} (#{uris.length} entries)") +end + +def build_annotated_uri(duration_seconds, title, uri) + dur = duration_seconds.to_s + ttl = title.gsub('"', '\\"').gsub(',', '\\,') + "annotate:liq_runlength=\"#{dur}\",liq_title=\"#{ttl}\":#{uri}" +end + +def process_show(target) + show = $db_s[:shows].where(slug: target).first + if show.nil? + show = $db_s[:shows].where(feed_url: target).first + end + if show.nil? + log("WARN: Show not found: #{target}") + return + end + + total = $db_p[:episodes].where(show_guid: show[:guid]).count + played_count = $db_p[:episodes].where(show_guid: show[:guid], played: 1).count + unplayed = total - played_count + + if unplayed <= 0 + if total > 0 + log("Resetting exhausted show: #{show[:title]}") + reset_show_episodes(show[:guid]) + first_ep = $db_p[:episodes].where(show_guid: show[:guid]).order(:created_at).first + if first_ep + mark_played(first_ep[:guid]) + uri = first_ep[:local_path] || first_ep[:url] + annotated = build_annotated_uri(first_ep[:duration_seconds], first_ep[:title], uri) + write_queue_file("#{show[:slug]}.txt", [annotated]) + else + log("WARN: No episodes after reset for #{show[:title]}") + end + else + log("WARN: No episodes for show: #{show[:title]}") + end + return + end + + ep = find_next_episode(show[:guid]) + if ep.nil? + log("WARN: No unplayed episode found for: #{show[:title]}") + return + end + + mark_played(ep[:guid]) + uri = ep[:local_path] || ep[:url] + annotated = build_annotated_uri(ep[:duration_seconds], ep[:title], uri) + write_queue_file("#{show[:slug]}.txt", [annotated]) + log("Selected: #{show[:title]} -> #{ep[:title]}") +end + +def process_stream(target, runlength) + log("Stream entry: #{target} (runlength=#{runlength})") + if runlength && runlength > 0 + annotated = "annotate:liq_runlength=\"#{runlength}\":#{target}" + write_queue_file("#{target.gsub(/[^a-zA-Z0-9]/, '_')}.stream.txt", [annotated]) + else + log("WARN: Stream entry missing runlength: #{target}") + end +end + +def main + ensure_dirs + ensure_schema + + schedule = load_schedule + if schedule.empty? + log("No schedule entries found in #{SCHEDULE_FILE}") + return + end + + now = Time.now + processed_shows = {} + processed_streams = {} + + schedule.each do |entry| + matches_now = false + m_min = entry[:min] == '*' || entry[:min].to_i == now.min + m_hour = entry[:hour] == '*' || entry[:hour].to_i == now.hour + m_dom = entry[:dom] == '*' || entry[:dom].to_i == now.day + m_mon = entry[:mon] == '*' || entry[:mon].to_i == now.month + m_dow = entry[:dow] == '*' || entry[:dow].to_i == now.wday % 7 + matches_now = m_min && m_hour && m_dom && m_mon && m_dow + + next unless matches_now + + case entry[:type] + when 'show' + if processed_shows.key?(entry[:target]) + log("Skipping duplicate show: #{entry[:target]}") + next + end + processed_shows[entry[:target]] = true + process_show(entry[:target]) + when 'stream' + key = "#{entry[:target]}_#{now.strftime('%Y%m%d%H%M')}" + if processed_streams.key?(key) + log("Skipping duplicate stream: #{entry[:target]}") + next + end + processed_streams[key] = true + process_stream(entry[:target], entry[:runlength]) + else + log("Unknown schedule type: #{entry[:type]}") + end + end + + if ARGV.include?('--json') + summary = [] + $db_s[:shows].all.each do |show| + total = $db_p[:episodes].where(show_guid: show[:guid]).count + played = $db_p[:episodes].where(show_guid: show[:guid], played: 1).count + summary << {slug: show[:slug], title: show[:title], total: total, played: played, unplayed: total - played} + end + puts JSON.pretty_generate(summary) + end +end + +main if $PROGRAM_NAME == __FILE__