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
# 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 "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__