This commit is contained in:
G. Gibson 2026-09-01 15:27:17 -07:00
commit 04098bc151
6 changed files with 385 additions and 187 deletions

View file

@ -2,14 +2,15 @@
# frozen_string_literal: true
require "json"
require "sqlite3"
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")
AUDIO_EXTS = [".mp3", ".m4a"]
STORAGE_DIR = nil
STATE_DIR = nil
@ -31,6 +32,37 @@ module RadioAutomation
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)
@ -44,22 +76,34 @@ module RadioAutomation
end
def self.open_subs_db
db = SQLite3::Database.new(SUBS_DB)
db.results_as_hash = true
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 = SQLite3::Database.new(PLAYED_DB)
db.results_as_hash = true
db.execute <<-SQL
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,
duration_seconds INTEGER,
enclosure_url TEXT,
runlength INTEGER,
played INTEGER DEFAULT 0,
played_at TEXT,
UNIQUE(show_slug, guid)
)
@ -67,80 +111,85 @@ module RadioAutomation
db
end
def self.find_audio_files(directory)
return [] unless Dir.exist?(directory)
Dir.glob(File.join(directory, "**", "*")).select do |f|
File.file?(f) && AUDIO_EXTS.any? { |ext| f.end_with?(ext) }
end.sort
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)
files = find_audio_files(File.join(PODCASTS_DIR, slug))
return nil if files.empty?
played_rows = played_db.query_all(
"SELECT file_path, played_at FROM episodes WHERE show_slug = ? AND played_at IS NOT NULL", slug
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]
)
played_map = played_rows.each_with_object({}) { |r, h| h[r["file_path"]] = r["played_at"] }
unplayed = files.reject { |f| played_map.key?(f) }
return unplayed.first if unplayed.any?
if played_map.any?
played_map.min_by { |_path, ts| ts.to_s }[0]
else
files.first
end
return nil if rows.empty?
rows.first
end
def self.write_pls(filepath, out_path)
abs = File.absolute_path(filepath)
content = "[playlist]\nFile1=#{abs}\nTitle1=Radio Episode\nLength1=-1\nNumberOfEntries=1\nVersion=2\n"
# 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.write(out_path, content)
File.open(out_path, "w") { |f| f.puts(line) }
end
def self.mark_as_played(slug, filepath, played_db)
played_db.execute(
"UPDATE episodes SET played_at = datetime('now') WHERE show_slug = ? AND file_path = ?",
[slug, filepath]
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 = subs_db.query_all("SELECT slug, name FROM shows ORDER BY name")
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"]
selected = select_unplayed_episode(slug, played_db)
if selected.nil?
log_info("#{slug}: no audio files found, skipping.")
ep = select_unplayed_episode(slug, played_db)
if ep.nil?
log_info("#{slug}: no unplayed episodes, skipping.")
skipped += 1
next
end
out_pls = File.join(PLAYLISTS_DIR, "#{slug}.pls")
write_pls(selected, out_pls)
mark_as_played(slug, selected, played_db)
log_info("#{slug}: queued #{File.basename(selected)}")
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 = subs_db.query_all("SELECT slug FROM shows ORDER BY name")
shows = jdb_query(subs_db, "SELECT slug FROM shows ORDER BY name")
summary = {}
shows.each do |show|
slug = show["slug"]
files = find_audio_files(File.join(PODCASTS_DIR, slug))
played_count = played_db.get_first_value(
"SELECT COUNT(*) FROM episodes WHERE show_slug = ? AND played_at IS NOT NULL", slug
)
summary[slug] = { "total_files" => files.size, "played_count" => played_count }
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