mirror of
https://github.com/mistergibson/radio.git
synced 2026-09-08 22:09:51 -07:00
Update
This commit is contained in:
parent
49ab0b725e
commit
8abce95f7d
7 changed files with 1032 additions and 866 deletions
|
|
@ -1,219 +1,244 @@
|
|||
#!/usr/bin/env jruby
|
||||
# frozen_string_literal: true
|
||||
#
|
||||
# update_playlists.rb - Select the next unplayed episode per show and write an
|
||||
# annotated URI queue file for station.liq to consume (JRuby/Sequel variant).
|
||||
#
|
||||
# Shares the same exclusive lockfile as fetch_podcasts.rb; skips cleanly if
|
||||
# the fetcher holds it. Databases run in WAL mode with a busy timeout.
|
||||
|
||||
require "sequel"
|
||||
require "json"
|
||||
require "jdbc/sqlite3"
|
||||
require "fileutils"
|
||||
require "optparse"
|
||||
require "logger"
|
||||
require "time"
|
||||
|
||||
Jdbc::SQLite3.load_driver
|
||||
ROOT = File.expand_path(File.dirname(__file__))
|
||||
CONFIG_PATH = File.join(ROOT, "config.json")
|
||||
|
||||
module RadioAutomation
|
||||
ROOT = File.expand_path("..", __dir__)
|
||||
CONFIG_PATH = File.join(ROOT, "config.json")
|
||||
$state_dir = nil
|
||||
$subs_db_path = nil
|
||||
$played_db_path = nil
|
||||
$podcasts_dir = nil
|
||||
$logs_dir = nil
|
||||
$playlists_dir = nil
|
||||
$lock_file = nil
|
||||
|
||||
STORAGE_DIR = nil
|
||||
STATE_DIR = nil
|
||||
SUBS_DB = nil
|
||||
PLAYED_DB = nil
|
||||
PODCASTS_DIR = nil
|
||||
PLAYLISTS_DIR = nil
|
||||
LOGS_DIR = nil
|
||||
def load_config
|
||||
JSON.parse(File.read(CONFIG_PATH))
|
||||
end
|
||||
|
||||
def self.init_paths
|
||||
cfg = JSON.parse(File.read(CONFIG_PATH))
|
||||
@storage = File.expand_path(cfg["storage"])
|
||||
self.STORAGE_DIR = @storage
|
||||
self.STATE_DIR = File.join(@storage, "state")
|
||||
self.SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
|
||||
self.PLAYED_DB = File.join(STATE_DIR, "played.db")
|
||||
self.PODCASTS_DIR = File.join(@storage, "podcasts")
|
||||
self.PLAYLISTS_DIR = File.join(@storage, "playlists")
|
||||
self.LOGS_DIR = File.join(@storage, "logs")
|
||||
def init_paths!
|
||||
cfg = load_config
|
||||
storage = File.realpath(cfg["storage"])
|
||||
$state_dir = File.join(storage, "state")
|
||||
$subs_db_path = File.join($state_dir, "subscriptions.db")
|
||||
$played_db_path = File.join($state_dir, "played.db")
|
||||
$podcasts_dir = File.join(storage, "podcasts")
|
||||
$logs_dir = File.join(storage, "logs")
|
||||
$playlists_dir = File.join(storage, "playlists")
|
||||
$lock_file = File.join($state_dir, "radio.lock")
|
||||
end
|
||||
|
||||
$log = Logger.new(STDOUT)
|
||||
$log.formatter = proc { |msg, _sev, _time, _prog| "#{Time.now} [INFO] #{msg}\n" }
|
||||
|
||||
def log_error(msg)
|
||||
$log.error(msg)
|
||||
end
|
||||
|
||||
def setup_logging!
|
||||
$log.instance_variable_set(:@logdev,
|
||||
Logger::LogDevice.new([STDOUT, File.join($logs_dir, "update.log")]))
|
||||
end
|
||||
|
||||
SHOWS_COLUMNS = {
|
||||
slug: { type: :string, primary_key: true },
|
||||
guid: { type: :string, null: false, unique: true },
|
||||
name: { type: :string, null: false },
|
||||
feed_url: { type: :string, null: false, unique: true },
|
||||
source: { type: :string, default: "manual" },
|
||||
opml_import: { type: :integer, default: 0 },
|
||||
archived: { type: :integer, default: 1 },
|
||||
media_class: { type: :string },
|
||||
created_at: { type: :string, default: Sequel.function(:datetime, "'now'") }
|
||||
}.freeze
|
||||
|
||||
EPISODES_COLUMNS = {
|
||||
id: { type: :integer, primary_key: true, auto_increment: true },
|
||||
show_slug: { type: :string, null: false },
|
||||
guid: { type: :string, null: false },
|
||||
title: { type: :string },
|
||||
file_path: { type: :string },
|
||||
enclosure_url: { type: :string },
|
||||
runlength: { type: :integer },
|
||||
played: { type: :integer, default: 0 },
|
||||
played_at: { type: :string }
|
||||
}.freeze
|
||||
|
||||
def connect_subs
|
||||
db = Sequel.connect("jdbc:sqlite:#{$subs_db_path}")
|
||||
db.extension :pragma
|
||||
db.pragma journal_mode: :wal
|
||||
db.pragma busy_timeout: 5000
|
||||
ensure_schema!(db, :shows, SHOWS_COLUMNS)
|
||||
db
|
||||
end
|
||||
|
||||
def connect_played
|
||||
db = Sequel.connect("jdbc:sqlite:#{$played_db_path}")
|
||||
db.extension :pragma
|
||||
db.pragma journal_mode: :wal
|
||||
db.pragma busy_timeout: 5000
|
||||
ensure_schema!(db, :episodes, EPISODES_COLUMNS)
|
||||
unless db.index_exists?(:episodes, [:show_slug, :played])
|
||||
db.create_index :episodes, [:show_slug, :played], name: :idx_episodes_show_played
|
||||
end
|
||||
db
|
||||
end
|
||||
|
||||
# --- JDBC connection helpers ---------------------------------------------
|
||||
|
||||
def self.jdb_connect(db_file)
|
||||
java.sql.DriverManager.getConnection("jdbc:sqlite:#{db_file}")
|
||||
end
|
||||
|
||||
def self.jdb_query(conn, sql, params = [])
|
||||
stmt = conn.prepareStatement(sql)
|
||||
params.each_with_index { |p, i| stmt.setObject(i + 1, p) }
|
||||
rs = stmt.executeQuery
|
||||
cols = []
|
||||
meta = rs.getMetaData
|
||||
(1..meta.getColumnCount).each { |i| cols << meta.getColumnName(i) }
|
||||
rows = []
|
||||
while rs.next
|
||||
row = {}
|
||||
cols.each { |c| row[c] = rs.getObject(c) }
|
||||
rows << row
|
||||
def ensure_schema!(db, table, columns)
|
||||
unless db.table_exists?(table)
|
||||
db.create_table(table) do |t|
|
||||
columns.each { |col, opts| t.column(col, **opts) }
|
||||
t.unique_constraint %i[show_slug guid] if table == :episodes
|
||||
end
|
||||
rs.close
|
||||
stmt.close
|
||||
rows
|
||||
return
|
||||
end
|
||||
|
||||
def self.jdb_exec(conn, sql, params = [])
|
||||
stmt = conn.prepareStatement(sql)
|
||||
params.each_with_index { |p, i| stmt.setObject(i + 1, p) }
|
||||
stmt.executeUpdate
|
||||
stmt.close
|
||||
existing = db.columns(table)
|
||||
columns.each do |col, opts|
|
||||
next if existing.include?(col)
|
||||
db.alter_table(table) { |t| t.add_column(col, **opts) }
|
||||
end
|
||||
end
|
||||
|
||||
def self.log_info(msg)
|
||||
puts "#{Time.now.iso8601} [INFO] #{msg}"
|
||||
append_log("update.log", msg)
|
||||
$lock_fh = nil
|
||||
|
||||
def acquire_lock!
|
||||
Dir.mkdir($state_dir) unless Dir.exist?($state_dir)
|
||||
fh = File.open($lock_file, File::RDWR | File::CREAT, 0o644)
|
||||
begin
|
||||
fh.flock(File::LOCK_EX | File::LOCK_NB)
|
||||
rescue Errno::EACCES, Errno::EAGAIN
|
||||
fh.close
|
||||
return false
|
||||
end
|
||||
fh.truncate(0)
|
||||
fh.write(Process.pid.to_s)
|
||||
fh.rewind
|
||||
$lock_fh = fh
|
||||
true
|
||||
end
|
||||
|
||||
def self.append_log(filename, msg)
|
||||
FileUtils.mkdir_p(LOGS_DIR)
|
||||
File.open(File.join(LOGS_DIR, filename), "a") { |f| f.puts msg }
|
||||
rescue StandardError
|
||||
nil
|
||||
def release_lock!
|
||||
return if $lock_fh.nil?
|
||||
begin
|
||||
$lock_fh.flock(File::LOCK_UN)
|
||||
$lock_fh.close
|
||||
ensure
|
||||
$lock_fh = nil
|
||||
end
|
||||
end
|
||||
|
||||
def self.open_subs_db
|
||||
db = jdb_connect(SUBS_DB)
|
||||
jdb_exec(db, <<-SQL)
|
||||
CREATE TABLE IF NOT EXISTS shows (
|
||||
slug TEXT PRIMARY KEY,
|
||||
guid TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
feed_url TEXT NOT NULL UNIQUE,
|
||||
source TEXT DEFAULT 'manual',
|
||||
opml_import INTEGER DEFAULT 0,
|
||||
archived INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
SQL
|
||||
db
|
||||
def annotate_uri(runlength, title, uri)
|
||||
def esc(v)
|
||||
'"' + v.to_s.gsub('"', '\\"') + '"'
|
||||
end
|
||||
"annotate:liq_runlength=#{esc(runlength)},liq_title=#{esc(title)}:" + uri
|
||||
end
|
||||
|
||||
def self.open_played_db
|
||||
db = jdb_connect(PLAYED_DB)
|
||||
jdb_exec(db, <<-SQL)
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
show_slug TEXT NOT NULL,
|
||||
guid TEXT NOT NULL,
|
||||
title TEXT,
|
||||
file_path TEXT,
|
||||
enclosure_url TEXT,
|
||||
runlength INTEGER,
|
||||
played INTEGER DEFAULT 0,
|
||||
played_at TEXT,
|
||||
UNIQUE(show_slug, guid)
|
||||
)
|
||||
SQL
|
||||
db
|
||||
end
|
||||
def select_next_episode(slug, played_db)
|
||||
played_db[:episodes]
|
||||
.where(show_slug: slug, played: 0)
|
||||
.order(:id.asc)
|
||||
.limit(1)
|
||||
.first
|
||||
end
|
||||
|
||||
# Pick the next unplayed episode for a show, keyed by guid.
|
||||
# Archived: prefer a local file_path; Live: use enclosure_url.
|
||||
def self.select_unplayed_episode(slug, played_db)
|
||||
rows = jdb_query(
|
||||
played_db,
|
||||
"SELECT guid, title, file_path, enclosure_url, runlength FROM episodes WHERE show_slug = ? AND played = 0 ORDER BY id ASC LIMIT 1",
|
||||
[slug]
|
||||
)
|
||||
return nil if rows.empty?
|
||||
rows.first
|
||||
end
|
||||
def mark_as_played(slug, guid, played_db)
|
||||
played_db[:episodes]
|
||||
.where(show_slug: slug, guid: guid)
|
||||
.update(played: 1, played_at: Sequel.function(:datetime, "'now'"))
|
||||
end
|
||||
|
||||
# Build the annotated URI line station.liq consumes.
|
||||
# Archived -> local file path; Live -> remote enclosure URL.
|
||||
def self.annotated_uri(ep)
|
||||
uri = ep["file_path"] || ep["enclosure_url"]
|
||||
return nil if uri.nil? || uri.to_s.empty?
|
||||
rl = ep["runlength"].to_i
|
||||
title = ep["title"].to_s.gsub('"', "'")
|
||||
"annotate:liq_runlength=\"#{rl}\",liq_title=\"#{title}\":#{uri}"
|
||||
end
|
||||
def write_queue_file(slug, ep, archived)
|
||||
uri = archived ? ep[:file_path] : ep[:enclosure_url]
|
||||
return nil if uri.nil? || uri.empty?
|
||||
line = annotate_uri(ep[:runlength], ep[:title], uri)
|
||||
out = File.join($playlists_dir, "#{slug}.txt")
|
||||
File.write(out, line + "\n")
|
||||
out
|
||||
end
|
||||
|
||||
def self.write_queue_line(slug, line, out_path)
|
||||
FileUtils.mkdir_p(File.dirname(out_path))
|
||||
File.open(out_path, "w") { |f| f.puts(line) }
|
||||
end
|
||||
|
||||
def self.mark_as_played(slug, guid, played_db)
|
||||
jdb_exec(
|
||||
played_db,
|
||||
"UPDATE episodes SET played = 1, played_at = datetime('now') WHERE show_slug = ? AND guid = ?",
|
||||
[slug, guid]
|
||||
)
|
||||
end
|
||||
|
||||
def self.update_all
|
||||
subs_db = open_subs_db
|
||||
played_db = open_played_db
|
||||
shows = jdb_query(subs_db, "SELECT slug, name, archived FROM shows ORDER BY name")
|
||||
|
||||
queued = 0
|
||||
skipped = 0
|
||||
shows.each do |show|
|
||||
slug = show["slug"]
|
||||
ep = select_unplayed_episode(slug, played_db)
|
||||
if ep.nil?
|
||||
log_info("#{slug}: no unplayed episodes, skipping.")
|
||||
skipped += 1
|
||||
next
|
||||
end
|
||||
line = annotated_uri(ep)
|
||||
if line.nil?
|
||||
log_info("#{slug}: episode #{ep['guid']} has no usable URI, skipping.")
|
||||
skipped += 1
|
||||
next
|
||||
end
|
||||
out_pls = File.join(PLAYLISTS_DIR, "#{slug}.txt")
|
||||
write_queue_line(slug, line, out_pls)
|
||||
mark_as_played(slug, ep["guid"], played_db)
|
||||
kind = ep["file_path"] ? "downloaded" : "live"
|
||||
log_info("#{slug}: queued #{ep['title']} [#{kind}] runlength=#{ep['runlength'].to_i}s")
|
||||
queued += 1
|
||||
def update_all
|
||||
subs_db = connect_subs
|
||||
played_db = connect_played
|
||||
shows = subs_db[:shows].order(:name).all
|
||||
updated = 0
|
||||
shows.each do |show|
|
||||
slug = show[:slug]
|
||||
archived = show[:archived] == 1
|
||||
ep = select_next_episode(slug, played_db)
|
||||
next if ep.nil?
|
||||
out = write_queue_file(slug, ep, archived)
|
||||
if out.nil?
|
||||
$log.warn("No playable URI for #{show[:name]} (#{slug}); skipping.")
|
||||
next
|
||||
end
|
||||
|
||||
subs_db.close
|
||||
played_db.close
|
||||
log_info("=== Update complete: #{queued} queued, #{skipped} skipped ===")
|
||||
mark_as_played(slug, ep[:guid], played_db)
|
||||
updated += 1
|
||||
$log.info("Queued #{slug}: #{ep[:title]} -> #{File.basename(out)}")
|
||||
end
|
||||
subs_db.disconnect
|
||||
played_db.disconnect
|
||||
$log.info("=== Update complete: #{updated} show(s) queued ===")
|
||||
end
|
||||
|
||||
def self.json_summary
|
||||
subs_db = open_subs_db
|
||||
played_db = open_played_db
|
||||
shows = jdb_query(subs_db, "SELECT slug FROM shows ORDER BY name")
|
||||
summary = {}
|
||||
shows.each do |show|
|
||||
slug = show["slug"]
|
||||
total = jdb_query(played_db, "SELECT COUNT(*) AS c FROM episodes WHERE show_slug = ?", [slug]).first["c"].to_i
|
||||
played = jdb_query(played_db, "SELECT COUNT(*) AS c FROM episodes WHERE show_slug = ? AND played = 1", [slug]).first["c"].to_i
|
||||
summary[slug] = { "total_episodes" => total, "played_count" => played, "unplayed" => total - played }
|
||||
def json_summary
|
||||
subs_db = connect_subs
|
||||
played_db = connect_played
|
||||
shows = subs_db[:shows].order(:name).all
|
||||
result = {}
|
||||
shows.each do |show|
|
||||
slug = show[:slug]
|
||||
counts = played_db[:episodes].where(show_slug: slug).hash_and_count
|
||||
total = counts.values.sum
|
||||
unplayed = played_db[:episodes].where(show_slug: slug, played: 0).count
|
||||
result[slug] = {
|
||||
name: show[:name],
|
||||
total: total,
|
||||
unplayed: unplayed,
|
||||
played: total - unplayed
|
||||
}
|
||||
end
|
||||
subs_db.disconnect
|
||||
played_db.disconnect
|
||||
puts JSON.pretty_generate(result)
|
||||
end
|
||||
|
||||
def main
|
||||
args = ARGV.dup
|
||||
json_mode = args.delete("--json")
|
||||
|
||||
init_paths!
|
||||
[$state_dir, $logs_dir, $playlists_dir].each do |dir|
|
||||
Dir.mkdir(dir) unless Dir.exist?(dir)
|
||||
end
|
||||
setup_logging!
|
||||
|
||||
if json_mode
|
||||
json_summary
|
||||
else
|
||||
if !acquire_lock!
|
||||
$log.info("Another radio process holds the lock; skipping this run.")
|
||||
return
|
||||
end
|
||||
subs_db.close
|
||||
played_db.close
|
||||
puts JSON.pretty_generate(summary)
|
||||
end
|
||||
|
||||
def self.main
|
||||
options = {}
|
||||
OptionParser.new do |opts|
|
||||
opts.banner = "Usage: update_playlists.rb [options]"
|
||||
opts.on("--json", "Emit JSON summary and exit") { options[:json] = true }
|
||||
end.parse!
|
||||
|
||||
init_paths
|
||||
FileUtils.mkdir_p(STATE_DIR)
|
||||
FileUtils.mkdir_p(LOGS_DIR)
|
||||
FileUtils.mkdir_p(PLAYLISTS_DIR)
|
||||
|
||||
if options[:json]
|
||||
json_summary
|
||||
else
|
||||
begin
|
||||
update_all
|
||||
ensure
|
||||
release_lock!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
RadioAutomation.main
|
||||
main if __FILE__ == $PROGRAM_NAME
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue