mirror of
https://github.com/mistergibson/radio.git
synced 2026-11-03 19:31:41 -08:00
bug fixes - update_playlists.rb
This commit is contained in:
parent
62a92a3740
commit
dc53dd8673
1 changed files with 215 additions and 159 deletions
|
|
@ -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__
|
||||
|
|
|
|||
Loading…
Reference in a new issue