radio/update_playlists.rb

259 lines
7.4 KiB
Ruby
Executable file

#!/usr/bin/env jruby
# frozen_string_literal: true
require 'sequel'
require 'json'
require 'digest/sha1'
SCRIPT_DIR = File.expand_path(File.dirname(__FILE__))
CONFIG_PATH = File.join(SCRIPT_DIR, "config.json")
CFG = JSON.parse(File.read(CONFIG_PATH)) rescue {}
STORAGE = CFG["storage"] || "/mnt/storage/radio"
STATE_DIR = File.join(STORAGE, "state")
LOG_DIR = File.join(STORAGE, "logs")
SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
PLAYED_DB = File.join(STATE_DIR, "played.db")
QUEUE_DIR = File.join(STORAGE, "queue")
SCHEDULE_FILE = File.join(SCRIPT_DIR, "schedule.txt")
$db_s = Sequel.connect("jdbc:sqlite:" + SUBS_DB)
$db_p = Sequel.connect("jdbc:sqlite:" + PLAYED_DB)
def log(msg)
puts "[INFO] #{msg}"
end
def ensure_dirs
[STATE_DIR, LOG_DIR, QUEUE_DIR].each do |d|
Dir.mkdir(d) unless Dir.exist?(d)
end
end
# def table_exists?(db, tbl)
# db[:sqlite_master].where(type: 'table', name: tbl).count > 0
# end
# def table_exists?(db, tbl)
# db[:sqlite_master].where("type = 'table' AND name = ?", tbl.to_s).count > 0
# end
# def table_exists?(db, tbl)
# result = db.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", [tbl.to_s]).first
# result && result.values.first.to_s.to_i > 0
# end
# def table_exists?(db, tbl)
# db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", [tbl.to_s]).fetch.first && true || false
# end
# def table_exists?(db, table_name)
# result = db.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='#{table_name.to_s}'")
# count = result.first[0] rescue 0
# count > 0
# end
def table_exists?(db, table_name)
result = db.fetch("SELECT COUNT(*) as cnt FROM sqlite_master WHERE type='table' AND name='#{table_name.to_s}'").first
result && result[:cnt].to_i > 0
end
def ensure_schema
$db_s.execute("PRAGMA journal_mode=WAL")
$db_s.execute("PRAGMA busy_timeout=5000")
$db_p.execute("PRAGMA journal_mode=WAL")
$db_p.execute("PRAGMA busy_timeout=5000")
unless table_exists?($db_s, :shows)
begin
$db_s.execute(%Q{
CREATE TABLE shows (
guid TEXT PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
feed_url TEXT NOT NULL,
archive INTEGER DEFAULT 0,
opml_import INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
})
rescue Exception => the_error
end
end
unless table_exists?($db_p, :episodes)
$db_p.execute(%Q{
CREATE TABLE episodes (
guid TEXT PRIMARY KEY,
show_guid TEXT NOT NULL,
title TEXT,
url TEXT,
duration_seconds REAL DEFAULT 0,
played INTEGER DEFAULT 0,
local_path TEXT,
file_size_bytes INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
})
end
end
def load_schedule
return [] unless File.exist?(SCHEDULE_FILE)
lines = File.readlines(SCHEDULE_FILE)
entries = []
lines.each do |raw_line|
line = raw_line.strip
next if line.empty? || line.start_with?('#')
parts = line.split(/\s+/)
min, hour, dom, mon, dow, type, target = parts[0..6]
runlength = parts[7] ? parts[7].to_i : nil
entries << {min: min, hour: hour, dom: dom, mon: mon, dow: dow, type: type, target: target, runlength: runlength}
end
entries
end
def find_next_episode(show_guid)
row = $db_p[:episodes].where(show_guid: show_guid, played: 0).order(:created_at).first
row
end
def mark_played(ep_guid)
$db_p[:episodes].where(guid: ep_guid).update(played: 1)
end
def reset_show_episodes(show_guid)
$db_p[:episodes].where(show_guid: show_guid).update(played: 0)
end
def write_queue_file(filename, uris)
path = File.join(QUEUE_DIR, filename)
File.open(path, 'w') do |f|
uris.each do |uri|
f.puts(uri)
end
end
log("Queue written: #{path} (#{uris.length} entries)")
end
def build_annotated_uri(duration_seconds, title, uri)
dur = duration_seconds.to_s
ttl = title.gsub('"', '\\"').gsub(',', '\\,')
"annotate:liq_runlength=\"#{dur}\",liq_title=\"#{ttl}\":#{uri}"
end
def process_show(target)
show = $db_s[:shows].where(slug: target).first
if show.nil?
show = $db_s[:shows].where(feed_url: target).first
end
if show.nil?
log("WARN: Show not found: #{target}")
return
end
total = $db_p[:episodes].where(show_guid: show[:guid]).count
played_count = $db_p[:episodes].where(show_guid: show[:guid], played: 1).count
unplayed = total - played_count
if unplayed <= 0
if total > 0
log("Resetting exhausted show: #{show[:title]}")
reset_show_episodes(show[:guid])
first_ep = $db_p[:episodes].where(show_guid: show[:guid]).order(:created_at).first
if first_ep
mark_played(first_ep[:guid])
uri = first_ep[:local_path] || first_ep[:url]
annotated = build_annotated_uri(first_ep[:duration_seconds], first_ep[:title], uri)
write_queue_file("#{show[:slug]}.txt", [annotated])
else
log("WARN: No episodes after reset for #{show[:title]}")
end
else
log("WARN: No episodes for show: #{show[:title]}")
end
return
end
ep = find_next_episode(show[:guid])
if ep.nil?
log("WARN: No unplayed episode found for: #{show[:title]}")
return
end
mark_played(ep[:guid])
uri = ep[:local_path] || ep[:url]
annotated = build_annotated_uri(ep[:duration_seconds], ep[:title], uri)
write_queue_file("#{show[:slug]}.txt", [annotated])
log("Selected: #{show[:title]} -> #{ep[:title]}")
end
def process_stream(target, runlength)
log("Stream entry: #{target} (runlength=#{runlength})")
if runlength && runlength > 0
annotated = "annotate:liq_runlength=\"#{runlength}\":#{target}"
write_queue_file("#{target.gsub(/[^a-zA-Z0-9]/, '_')}.stream.txt", [annotated])
else
log("WARN: Stream entry missing runlength: #{target}")
end
end
def main
ensure_dirs
ensure_schema
schedule = load_schedule
if schedule.empty?
log("No schedule entries found in #{SCHEDULE_FILE}")
return
end
now = Time.now
processed_shows = {}
processed_streams = {}
schedule.each do |entry|
matches_now = false
m_min = entry[:min] == '*' || entry[:min].to_i == now.min
m_hour = entry[:hour] == '*' || entry[:hour].to_i == now.hour
m_dom = entry[:dom] == '*' || entry[:dom].to_i == now.day
m_mon = entry[:mon] == '*' || entry[:mon].to_i == now.month
m_dow = entry[:dow] == '*' || entry[:dow].to_i == now.wday % 7
matches_now = m_min && m_hour && m_dom && m_mon && m_dow
next unless matches_now
case entry[:type]
when 'show'
if processed_shows.key?(entry[:target])
log("Skipping duplicate show: #{entry[:target]}")
next
end
processed_shows[entry[:target]] = true
process_show(entry[:target])
when 'stream'
key = "#{entry[:target]}_#{now.strftime('%Y%m%d%H%M')}"
if processed_streams.key?(key)
log("Skipping duplicate stream: #{entry[:target]}")
next
end
processed_streams[key] = true
process_stream(entry[:target], entry[:runlength])
else
log("Unknown schedule type: #{entry[:type]}")
end
end
if ARGV.include?('--json')
summary = []
$db_s[:shows].all.each do |show|
total = $db_p[:episodes].where(show_guid: show[:guid]).count
played = $db_p[:episodes].where(show_guid: show[:guid], played: 1).count
summary << {slug: show[:slug], title: show[:title], total: total, played: played, unplayed: total - played}
end
puts JSON.pretty_generate(summary)
end
end
main if $PROGRAM_NAME == __FILE__