mirror of
https://github.com/mistergibson/radio.git
synced 2026-09-08 22:09:51 -07:00
bug fixes: installer_for_jruby and fetch_podcasts.rb
This commit is contained in:
parent
1c4fc11b47
commit
4d9201bf24
3 changed files with 567 additions and 686 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -55,7 +55,35 @@ rm -f "${STORAGE_ROOT}/state/subscriptions.db" \
|
|||
"${STORAGE_ROOT}/state/subscriptions.db-shm" \
|
||||
"${STORAGE_ROOT}/state/played.db-wal" \
|
||||
"${STORAGE_ROOT}/state/played.db-shm"
|
||||
echo "==> Databases dropped (will be recreated by scripts on first run)"
|
||||
echo "==> Databases dropped (will be recreated now)"
|
||||
# Initialize database schemas
|
||||
sqlite3 "${STORAGE_ROOT}/state/subscriptions.db" <<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS shows (
|
||||
guid TEXT PRIMARY KEY,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
title TEXT,
|
||||
feed_url TEXT UNIQUE NOT NULL,
|
||||
archive INTEGER DEFAULT 0,
|
||||
opml_import INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
SQL
|
||||
|
||||
sqlite3 "${STORAGE_ROOT}/state/played.db" <<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
guid TEXT PRIMARY KEY,
|
||||
show_guid TEXT NOT NULL REFERENCES shows(guid),
|
||||
title TEXT,
|
||||
url TEXT,
|
||||
duration_seconds INTEGER,
|
||||
played INTEGER DEFAULT 0,
|
||||
local_path TEXT,
|
||||
file_size_bytes INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
SQL
|
||||
|
||||
echo "Database schemas initialized."
|
||||
|
||||
# --- Install gems one at a time ---------------------------------------------
|
||||
export GEM_HOME="${GEMS_DIR}"
|
||||
|
|
@ -155,6 +183,11 @@ WantedBy=multi-user.target
|
|||
EOF
|
||||
systemctl daemon-reload
|
||||
echo "==> Installed ${UNIT_PATH}"
|
||||
# After the mkdir -p "$STORAGE/state" line, add:
|
||||
mkdir -p "$STORAGE/state"
|
||||
chown -R liquidsoap:liquidsoap "$STORAGE/state"
|
||||
chmod 755 "$STORAGE/state"
|
||||
chmod 664 "$STORAGE/state/"*.db 2>/dev/null || true
|
||||
|
||||
# --- Cron entries (liquidsoap user's crontab already exists from package) -----
|
||||
CRON_FETCH="0 * * * * cd ${INSTALL_DIR} && ${INSTALL_DIR}/run_radio.sh fetch_podcasts.rb --fetch-all >> ${STORAGE_ROOT}/logs/fetch_cron.log 2>&1"
|
||||
|
|
|
|||
|
|
@ -1,21 +1,22 @@
|
|||
#!/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.
|
||||
# update_playlists.rb - Select next unplayed episode per show and write queue files
|
||||
#
|
||||
# Reads from played.db (episode records with played flag), writes queue files
|
||||
# under <storage>/playlists/. Cycles archived shows when all episodes are
|
||||
# already played.
|
||||
# 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]
|
||||
#
|
||||
# Options:
|
||||
# --json Emit a JSON summary of each show's episode counts to stdout.
|
||||
|
||||
require "json"
|
||||
require "sequel"
|
||||
require "jdbc/sqlite3"
|
||||
require "digest/sha1"
|
||||
require "fileutils"
|
||||
require "json"
|
||||
|
||||
SCRIPT_DIR = File.expand_path(File.dirname(__FILE__))
|
||||
CONFIG_PATH = File.join(SCRIPT_DIR, "config.json")
|
||||
|
|
@ -23,196 +24,180 @@ 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
|
||||
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 = 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")
|
||||
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")
|
||||
|
||||
[DIRS_TO_CREATE].each do |d|
|
||||
Dir.mkdir(d) unless Dir.exist?(d)
|
||||
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)
|
||||
|
||||
LOG_FILE = File.join(LOG_DIR, "update_playlists.log")
|
||||
$log_fh = File.open(LOG_FILE, "a+")
|
||||
|
||||
$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")
|
||||
$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}")
|
||||
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
|
||||
def table_exists?(db, tbl)
|
||||
db[:sqlite_master].where(type: "table", name: tbl).count > 0
|
||||
rescue Exception => e
|
||||
false
|
||||
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
|
||||
)
|
||||
def ensure_schema(db_subs, db_eps)
|
||||
unless table_exists?(db_subs, :shows)
|
||||
db_subs.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,
|
||||
description TEXT DEFAULT '',
|
||||
category TEXT DEFAULT '',
|
||||
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
|
||||
)
|
||||
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
|
||||
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
|
||||
$stdout.sync = true
|
||||
|
||||
begin
|
||||
acquire_lock!
|
||||
begin
|
||||
update_all
|
||||
ensure
|
||||
release_lock!
|
||||
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
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
if json_output
|
||||
puts JSON.pretty_generate(results)
|
||||
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", "Fatal error: #{e.class} #{e.message}")
|
||||
log("ERROR", e.backtrace.first(10).join("\n"))
|
||||
log("ERROR", "#{e.class}: #{e.message}")
|
||||
log("ERROR", e.backtrace.first(5).join("\n"))
|
||||
exit 1
|
||||
end
|
||||
|
||||
db_subs.disconnect
|
||||
db_eps.disconnect
|
||||
$log_fh.close
|
||||
main if __FILE__ == $PROGRAM_NAME
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue