radio/update_playlists.rb
2026-09-01 15:27:17 -07:00

219 lines
6.1 KiB
Ruby
Executable file

#!/usr/bin/env jruby
# frozen_string_literal: true
require "json"
require "jdbc/sqlite3"
require "fileutils"
require "optparse"
Jdbc::SQLite3.load_driver
module RadioAutomation
ROOT = File.expand_path("..", __dir__)
CONFIG_PATH = File.join(ROOT, "config.json")
STORAGE_DIR = nil
STATE_DIR = nil
SUBS_DB = nil
PLAYED_DB = nil
PODCASTS_DIR = nil
PLAYLISTS_DIR = nil
LOGS_DIR = nil
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")
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
end
rs.close
stmt.close
rows
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
end
def self.log_info(msg)
puts "#{Time.now.iso8601} [INFO] #{msg}"
append_log("update.log", msg)
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
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
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
# 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
# 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 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
end
subs_db.close
played_db.close
log_info("=== Update complete: #{queued} queued, #{skipped} skipped ===")
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 }
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
update_all
end
end
end
RadioAutomation.main