mirror of
https://github.com/mistergibson/radio.git
synced 2026-09-08 22:09:51 -07:00
Minor tweaks
This commit is contained in:
parent
0c5f864007
commit
952c9ae5a9
9 changed files with 1408 additions and 2024 deletions
|
|
@ -1,138 +1,154 @@
|
|||
#!/usr/bin/env jruby
|
||||
# frozen_string_literal: true
|
||||
#
|
||||
# update_playlists.rb - Regenerate per-show playlist files based on playback history.
|
||||
# JRuby-compatible (Ruby 3.1+ baseline).
|
||||
|
||||
require "json"
|
||||
require "sqlite3"
|
||||
require "fileutils"
|
||||
require "time"
|
||||
require "optparse"
|
||||
|
||||
AUDIO_ROOT = Pathname.new(File.expand_path(__dir__))
|
||||
PLAYLISTS_DIR = AUDIO_ROOT.join("playlists")
|
||||
STATE_DB = AUDIO_ROOT.join("state/played.db")
|
||||
SUBS_DB = AUDIO_ROOT.join("state/subscriptions.db")
|
||||
SHOWS_DIR = AUDIO_ROOT.join("podcasts")
|
||||
module RadioAutomation
|
||||
ROOT = File.expand_path("..", __dir__)
|
||||
STATE_DIR = File.join(ROOT, "state")
|
||||
SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
|
||||
PLAYED_DB = File.join(STATE_DIR, "played.db")
|
||||
PODCASTS_DIR = File.join(ROOT, "podcasts")
|
||||
PLAYLISTS_DIR = File.join(ROOT, "playlists")
|
||||
LOGS_DIR = File.join(ROOT, "logs")
|
||||
AUDIO_EXTS = [".mp3", ".m4a"]
|
||||
|
||||
module Radio
|
||||
class PlaylistUpdater
|
||||
def initialize
|
||||
FileUtils.mkdir_p(PLAYLISTS_DIR)
|
||||
FileUtils.mkdir_p(STATE_DB.dirname)
|
||||
@played_db = connect_played_db
|
||||
@subs_db = connect_subs_db
|
||||
end
|
||||
def self.log_info(msg)
|
||||
puts "#{Time.now.iso8601} [INFO] #{msg}"
|
||||
append_log("update.log", msg)
|
||||
end
|
||||
|
||||
attr_reader :played_db, :subs_db
|
||||
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 connect_played_db
|
||||
conn = SQLite3::Database.new(STATE_DB.to_s)
|
||||
conn.execute(<<~SQL)
|
||||
CREATE TABLE IF NOT EXISTS played (
|
||||
filename TEXT PRIMARY KEY,
|
||||
show TEXT,
|
||||
played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
SQL
|
||||
conn
|
||||
end
|
||||
def self.open_subs_db
|
||||
db = SQLite3::Database.new(SUBS_DB)
|
||||
db.results_as_hash = true
|
||||
db
|
||||
end
|
||||
|
||||
def connect_subs_db
|
||||
conn = SQLite3::Database.new(SUBS_DB.to_s)
|
||||
conn
|
||||
end
|
||||
|
||||
# Build a .pls for each show: pick an unplayed episode, mark it played.
|
||||
def regenerate_all
|
||||
shows = subs_db.execute("SELECT name, slug FROM shows ORDER BY name")
|
||||
if shows.empty?
|
||||
puts "No shows registered."
|
||||
return
|
||||
end
|
||||
|
||||
regenerated = 0
|
||||
shows.each do |_name, slug|
|
||||
if regenerate_show_pls(slug)
|
||||
regenerated += 1
|
||||
end
|
||||
end
|
||||
|
||||
puts "Regenerated #{regenerated} playlist(s)."
|
||||
end
|
||||
|
||||
def regenerate_show_pls(slug)
|
||||
show_dir = SHOWS_DIR.join(slug)
|
||||
return false unless Dir.exist?(show_dir)
|
||||
|
||||
# Gather candidate audio files recursively
|
||||
candidates = Dir.glob(show_dir.join("**/*.{mp3,m4a,ogg,flac}")).sort
|
||||
return false if candidates.empty?
|
||||
|
||||
# Determine which have already been played
|
||||
played_rows = played_db.execute("SELECT filename FROM played WHERE show=?", slug)
|
||||
played_set = played_rows.map { |r| File.basename(r[0]) }.to_set
|
||||
|
||||
unplayed = candidates.reject { |path| played_set.include?(File.basename(path)) }
|
||||
|
||||
# Fall back to the least-recently-played (or any) if nothing is unplayed
|
||||
chosen = unplayed.first || lru_episode(candidates, slug)
|
||||
return false if chosen.nil?
|
||||
|
||||
pls_path = PLAYLISTS_DIR.join("#{slug}.pls")
|
||||
File.write(pls_path, "#{chosen}\n")
|
||||
|
||||
# Record playback so the next cycle picks a different episode
|
||||
played_db.execute(
|
||||
"INSERT OR REPLACE INTO played (filename, show) VALUES (?, ?)",
|
||||
[chosen.to_s, slug]
|
||||
def self.open_played_db
|
||||
db = SQLite3::Database.new(PLAYED_DB)
|
||||
db.results_as_hash = true
|
||||
db.execute <<-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,
|
||||
played_at TEXT,
|
||||
UNIQUE(show_slug, guid)
|
||||
)
|
||||
played_db.commit
|
||||
SQL
|
||||
db
|
||||
end
|
||||
|
||||
puts " #{slug}: queued #{File.basename(chosen)}"
|
||||
true
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
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
|
||||
end
|
||||
|
||||
def lru_episode(candidates, slug)
|
||||
played_rows = played_db.execute(
|
||||
"SELECT filename, played_at FROM played WHERE show=? ORDER BY played_at ASC", slug
|
||||
)
|
||||
played_map = played_rows.to_h { |fname, ts| [File.basename(fname), ts] }
|
||||
candidates.min_by { |c| played_map[File.basename(c)] || Time.at(0) }
|
||||
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"
|
||||
FileUtils.mkdir_p(File.dirname(out_path))
|
||||
File.write(out_path, content)
|
||||
end
|
||||
|
||||
def dump_json
|
||||
shows = subs_db.execute("SELECT name, slug FROM shows ORDER BY name")
|
||||
out = {}
|
||||
shows.each do |_name, slug|
|
||||
show_dir = SHOWS_DIR.join(slug)
|
||||
next unless Dir.exist?(show_dir)
|
||||
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]
|
||||
)
|
||||
end
|
||||
|
||||
files = Dir.glob(show_dir.join("**/*.{mp3,m4a,ogg,flac}")).size
|
||||
played = played_db.get_first_row("SELECT COUNT(*) FROM played WHERE show=?", slug)[0]
|
||||
out[slug] = { total_files: files, played: played }
|
||||
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.each do |show|
|
||||
slug = show["slug"]
|
||||
selected = select_unplayed_episode(slug, played_db)
|
||||
if selected.nil?
|
||||
log_info("#{slug}: no audio files found, skipping.")
|
||||
next
|
||||
end
|
||||
puts JSON.pretty_generate(out)
|
||||
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)}")
|
||||
end
|
||||
|
||||
def close
|
||||
played_db&.close
|
||||
subs_db&.close
|
||||
subs_db.close
|
||||
played_db.close
|
||||
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")
|
||||
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 }
|
||||
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!
|
||||
|
||||
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
|
||||
|
||||
require "set"
|
||||
|
||||
def main
|
||||
u = Radio::PlaylistUpdater.new
|
||||
if ARGV.include?("--json")
|
||||
u.dump_json
|
||||
else
|
||||
u.regenerate_all
|
||||
end
|
||||
ensure
|
||||
u&.close
|
||||
end
|
||||
|
||||
main
|
||||
RadioAutomation.main
|
||||
|
|
|
|||
Loading…
Reference in a new issue