bug fixes - update_playlists.rb

This commit is contained in:
G. Gibson 2026-09-17 14:49:12 -07:00
commit dc53dd8673

View file

@ -1,203 +1,259 @@
#!/usr/bin/env jruby #!/usr/bin/env jruby
# frozen_string_literal: true # 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 <storage>/queue/<slug>.txt with annotated URI or "SKIP"
#
# Usage:
# ./update_playlists.rb [--json]
require "sequel" require 'sequel'
require "jdbc/sqlite3" require 'json'
require "digest/sha1" require 'digest/sha1'
require "fileutils"
require "json"
SCRIPT_DIR = File.expand_path(File.dirname(__FILE__)) SCRIPT_DIR = File.expand_path(File.dirname(__FILE__))
CONFIG_PATH = File.join(SCRIPT_DIR, "config.json") CONFIG_PATH = File.join(SCRIPT_DIR, "config.json")
def load_config CFG = JSON.parse(File.read(CONFIG_PATH)) rescue {}
raw = File.read(CONFIG_PATH) STORAGE = CFG["storage"] || "/mnt/storage/radio"
cfg = JSON.parse(raw) STATE_DIR = File.join(STORAGE, "state")
storage = cfg["storage"].to_s.strip LOG_DIR = File.join(STORAGE, "logs")
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 = 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") SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
PLAYED_DB = File.join(STATE_DIR, "played.db") PLAYED_DB = File.join(STATE_DIR, "played.db")
MEDIA_DIRS = %w[music podcasts jingles announcements] QUEUE_DIR = File.join(STORAGE, "queue")
MEDIA_DIRS.each do |d| SCHEDULE_FILE = File.join(SCRIPT_DIR, "schedule.txt")
FileUtils.mkdir_p(File.join(CFG[:storage], d))
end
FileUtils.mkdir_p(STATE_DIR)
FileUtils.mkdir_p(QUEUE_DIR)
FileUtils.mkdir_p(LOG_DIR)
LOG_FILE = File.join(LOG_DIR, "update_playlists.log") $db_s = Sequel.connect("jdbc:sqlite:" + SUBS_DB)
$log_fh = File.open(LOG_FILE, "a+") $db_p = Sequel.connect("jdbc:sqlite:" + PLAYED_DB)
def log(level, msg) def log(msg)
ts = Time.now.strftime("%Y-%m-%d %H:%M:%S") puts "[INFO] #{msg}"
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}")
end end
def table_exists?(db, tbl) def ensure_dirs
db[:sqlite_master].where(type: "table", name: tbl).count > 0 [STATE_DIR, LOG_DIR, QUEUE_DIR].each do |d|
rescue Exception => e Dir.mkdir(d) unless Dir.exist?(d)
false end
end end
def ensure_schema(db_subs, db_eps) # def table_exists?(db, tbl)
unless table_exists?(db_subs, :shows) # db[:sqlite_master].where(type: 'table', name: tbl).count > 0
db_subs.execute <<-SQL # end
CREATE TABLE IF NOT EXISTS shows (
# 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, guid TEXT PRIMARY KEY,
slug TEXT UNIQUE NOT NULL, slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
feed_url TEXT NOT NULL, feed_url TEXT NOT NULL,
description TEXT DEFAULT '',
category TEXT DEFAULT '',
audio_only INTEGER DEFAULT 1,
archive INTEGER DEFAULT 0, archive INTEGER DEFAULT 0,
opml_import INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) );
SQL })
rescue Exception => the_error
end end
unless table_exists?(db_eps, :episodes) end
db_eps.execute <<-SQL
CREATE TABLE IF NOT EXISTS episodes ( unless table_exists?($db_p, :episodes)
$db_p.execute(%Q{
CREATE TABLE episodes (
guid TEXT PRIMARY KEY, guid TEXT PRIMARY KEY,
show_guid TEXT NOT NULL, show_guid TEXT NOT NULL,
title TEXT NOT NULL, title TEXT,
enclosure_url TEXT NOT NULL, url TEXT,
enclosure_type TEXT DEFAULT '', duration_seconds REAL DEFAULT 0,
duration_sec INTEGER DEFAULT 0,
pub_date TEXT DEFAULT '',
local_path TEXT DEFAULT '',
played INTEGER DEFAULT 0, played INTEGER DEFAULT 0,
downloaded INTEGER DEFAULT 0, local_path TEXT,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP file_size_bytes INTEGER DEFAULT 0,
) created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
SQL );
})
end end
end end
$stdout.sync = true 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
begin def find_next_episode(show_guid)
db_subs = Sequel.connect("jdbc:sqlite:" + SUBS_DB) row = $db_p[:episodes].where(show_guid: show_guid, played: 0).order(:created_at).first
db_eps = Sequel.connect("jdbc:sqlite:" + PLAYED_DB) row
ensure_schema(db_subs, db_eps) end
json_output = ARGV.include?("--json") def mark_played(ep_guid)
results = [] $db_p[:episodes].where(guid: ep_guid).update(played: 1)
end
shows = db_subs[:shows].all def reset_show_episodes(show_guid)
if shows.empty? $db_p[:episodes].where(show_guid: show_guid).update(played: 0)
log("INFO", "No shows registered.") 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 else
shows.each do |show| log("WARN: No episodes after reset for #{show[:title]}")
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 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 else
File.write(qf, "SKIP\n") log("WARN: No episodes for show: #{show[:title]}")
end
return
end end
results << { ep = find_next_episode(show[:guid])
"slug" => slug, if ep.nil?
"total" => total, log("WARN: No unplayed episode found for: #{show[:title]}")
"unplayed" => unplayed, return
"picked" => chosen ? chosen[:title] : nil 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
end end
if json_output def main
puts JSON.pretty_generate(results) ensure_dirs
ensure_schema
schedule = load_schedule
if schedule.empty?
log("No schedule entries found in #{SCHEDULE_FILE}")
return
end end
log("INFO", "Done. Processed #{results.size} shows.") now = Time.now
processed_shows = {}
processed_streams = {}
begin schedule.each do |entry|
db_subs.disconnect matches_now = false
rescue Exception => e m_min = entry[:min] == '*' || entry[:min].to_i == now.min
log("WARN", "Error disconnecting subs: #{e.message}") m_hour = entry[:hour] == '*' || entry[:hour].to_i == now.hour
end m_dom = entry[:dom] == '*' || entry[:dom].to_i == now.day
begin m_mon = entry[:mon] == '*' || entry[:mon].to_i == now.month
db_eps.disconnect m_dow = entry[:dow] == '*' || entry[:dow].to_i == now.wday % 7
rescue Exception => e matches_now = m_min && m_hour && m_dom && m_mon && m_dow
log("WARN", "Error disconnecting eps: #{e.message}")
end
$log_fh.close
rescue Interrupt next unless matches_now
log("INFO", "Interrupted.")
exit 1 case entry[:type]
rescue Exception => e when 'show'
log("ERROR", "#{e.class}: #{e.message}") if processed_shows.key?(entry[:target])
log("ERROR", e.backtrace.first(5).join("\n")) log("Skipping duplicate show: #{entry[:target]}")
exit 1 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 end
main if __FILE__ == $PROGRAM_NAME 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__