mirror of
https://github.com/mistergibson/radio.git
synced 2026-09-08 22:09:51 -07:00
218 lines
5.7 KiB
Ruby
Executable file
218 lines
5.7 KiB
Ruby
Executable file
#!/usr/bin/env jruby
|
|
# frozen_string_literal: true
|
|
#
|
|
# update_playlists.rb - Select the next unplayed episode per show and write
|
|
# annotated-URI queue files for station.liq to consume.
|
|
#
|
|
# Reads from played.db (episode records with played flag), writes queue files
|
|
# under <storage>/playlists/. Cycles archived shows when all episodes are
|
|
# already played.
|
|
#
|
|
# Usage:
|
|
# ./update_playlists.rb [--json]
|
|
#
|
|
# Options:
|
|
# --json Emit a JSON summary of each show's episode counts to stdout.
|
|
|
|
require "json"
|
|
require "sequel"
|
|
|
|
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)
|
|
raise "FATAL: storage missing from config.json" unless cfg["storage"] && !cfg["storage"].to_s.empty?
|
|
cfg
|
|
end
|
|
|
|
CFG = load_config
|
|
STORAGE_ROOT = CFG["storage"]
|
|
STATE_DIR = File.join(STORAGE_ROOT, "state")
|
|
PLAYLISTS_DIR = File.join(STORAGE_ROOT, "playlists")
|
|
LOG_DIR = File.join(STATE_DIR, "logs")
|
|
LOCK_FILE = File.join(STATE_DIR, "radio.lock")
|
|
SUBS_DB_PATH = File.join(STATE_DIR, "subscriptions.db")
|
|
EPISODES_DB_PATH = File.join(STATE_DIR, "episodes.db")
|
|
|
|
[DIRS_TO_CREATE].each do |d|
|
|
Dir.mkdir(d) unless Dir.exist?(d)
|
|
end
|
|
|
|
$log_fh = File.open(File.join(LOG_DIR, "update_playlists.log"), "a")
|
|
def log(level, msg)
|
|
ts = Time.now.strftime("%Y-%m-%d %H:%M:%S")
|
|
line = "[#{ts}] [#{level}] #{msg}"
|
|
puts(line)
|
|
$log_fh.write("#{line}\n")
|
|
$log_fh.flush
|
|
end
|
|
|
|
db_subs = Sequel.jdbc("sqlite:", SUBS_DB_PATH)
|
|
db_eps = Sequel.jdbc("sqlite:", EPISODES_DB_PATH)
|
|
|
|
db_subs.execute("PRAGMA journal_mode=WAL;")
|
|
db_eps.execute("PRAGMA journal_mode=WAL;")
|
|
db_subs.execute("PRAGMA busy_timeout=5000;")
|
|
db_eps.execute("PRAGMA busy_timeout=5000;")
|
|
|
|
def table_exists?(db, name)
|
|
db[:sqlite_master].where(type: "table", name: name).count > 0
|
|
end
|
|
|
|
def ensure_schema(db, path, label)
|
|
if !table_exists?(db, "shows")
|
|
db.execute <<-SQL
|
|
CREATE TABLE IF NOT EXISTS shows (
|
|
guid TEXT PRIMARY KEY,
|
|
slug TEXT UNIQUE NOT NULL,
|
|
title TEXT NOT NULL,
|
|
feed_url TEXT NOT NULL,
|
|
audio_only INTEGER DEFAULT 1,
|
|
archive INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
SQL
|
|
log("INFO", "Created 'shows' table in #{label}")
|
|
end
|
|
|
|
if !table_exists?(db, "episodes")
|
|
db.execute <<-SQL
|
|
CREATE TABLE IF NOT EXISTS episodes (
|
|
guid TEXT PRIMARY KEY,
|
|
show_guid TEXT NOT NULL REFERENCES shows(guid),
|
|
title TEXT,
|
|
enclosure_url TEXT,
|
|
runlength INTEGER DEFAULT 0,
|
|
played INTEGER DEFAULT 0,
|
|
downloaded INTEGER DEFAULT 0,
|
|
local_path TEXT,
|
|
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
SQL
|
|
log("INFO", "Created 'episodes' table in #{label}")
|
|
end
|
|
end
|
|
|
|
ensure_schema(db_subs, SUBS_DB_PATH, "subscriptions.db")
|
|
ensure_schema(db_eps, EPISODES_DB_PATH, "episodes.db")
|
|
|
|
def acquire_lock!
|
|
@lock_fh = File.new(LOCK_FILE, "a+")
|
|
begin
|
|
@lock_fh.flock(File::LOCK_EX | File::LOCK_NB)
|
|
rescue IOError
|
|
log("WARN", "Another radio process holds the lock; skipping this run.")
|
|
exit 0
|
|
end
|
|
end
|
|
|
|
def release_lock!
|
|
@lock_fh.flock(File::LOCK_UN) if @lock_fh
|
|
@lock_fh.close if @lock_fh
|
|
@lock_fh = nil
|
|
end
|
|
|
|
def make_annotated_uri(ep)
|
|
uri = ep[:local_path] || ep[:enclosure_url]
|
|
rl = ep[:runlength].to_i
|
|
ttl = ep[:title].to_s.gsub('"', '\\"')
|
|
"annotate:liq_runlength=\"#{rl}\",liq_title=\"#{ttl}\":#{uri}"
|
|
end
|
|
|
|
def select_next_episode(show_guid)
|
|
# Try to find an unplayed episode
|
|
ep = db_eps[:episodes].where(show_guid: show_guid, played: 0).order(:fetched_at.asc).first
|
|
|
|
if ep.nil?
|
|
# All episodes played — reset all to unplayed, then pick the first
|
|
count = db_eps[:episodes].where(show_guid: show_guid).count
|
|
if count > 0
|
|
db_eps[:episodes].where(show_guid: show_guid).update(played: 0)
|
|
log("INFO", "Reset all episodes for show #{show_guid} to played=0 (cycling)")
|
|
ep = db_eps[:episodes].where(show_guid: show_guid).order(:fetched_at.asc).first
|
|
end
|
|
end
|
|
|
|
ep
|
|
end
|
|
|
|
def update_show_queue(show)
|
|
slug = show[:slug]
|
|
queue_dir = File.join(PLAYLISTS_DIR, slug)
|
|
Dir.mkdir(queue_dir) unless Dir.exist?(queue_dir)
|
|
|
|
ep = select_next_episode(show[:guid])
|
|
|
|
if ep.nil?
|
|
log("WARN", "No episodes available for show '#{slug}'")
|
|
return false
|
|
end
|
|
|
|
annotated = make_annotated_uri(ep)
|
|
queue_file = File.join(queue_dir, "next.uri")
|
|
File.write(queue_file, "#{annotated}\n")
|
|
|
|
# Mark as played
|
|
db_eps[:episodes].where(guid: ep[:guid]).update(played: 1)
|
|
|
|
log("INFO", "Queued episode '#{ep[:title]}' for show '#{slug}' (runlength=#{ep[:runlength]}s)")
|
|
true
|
|
end
|
|
|
|
def update_all
|
|
shows = db_subs[:shows].all
|
|
|
|
if shows.empty?
|
|
log("INFO", "No shows registered; nothing to do.")
|
|
return
|
|
end
|
|
|
|
queued_count = 0
|
|
failed_count = 0
|
|
|
|
shows.each do |show|
|
|
begin
|
|
if update_show_queue(show)
|
|
queued_count += 1
|
|
else
|
|
failed_count += 1
|
|
end
|
|
rescue Exception => e
|
|
failed_count += 1
|
|
log("ERROR", "Failed to update queue for show '#{show[:slug]}': #{e.class} #{e.message}")
|
|
end
|
|
end
|
|
|
|
log("INFO", "Update complete: #{queued_count} queued, #{failed_count} failed out of #{shows.size} shows")
|
|
|
|
if ARGV.include?("--json")
|
|
summary = {}
|
|
shows.each do |show|
|
|
total = db_eps[:episodes].where(show_guid: show[:guid]).count
|
|
played = db_eps[:episodes].where(show_guid: show[:guid], played: 1).count
|
|
unplayed = total - played
|
|
summary[show[:slug]] = { total: total, played: played, unplayed: unplayed }
|
|
end
|
|
puts(JSON.pretty_generate(summary))
|
|
end
|
|
end
|
|
|
|
begin
|
|
acquire_lock!
|
|
begin
|
|
update_all
|
|
ensure
|
|
release_lock!
|
|
end
|
|
rescue Exception => e
|
|
log("ERROR", "Fatal error: #{e.class} #{e.message}")
|
|
log("ERROR", e.backtrace.first(10).join("\n"))
|
|
exit 1
|
|
end
|
|
|
|
db_subs.disconnect
|
|
db_eps.disconnect
|
|
$log_fh.close
|
|
|