a bit more self-contained

This commit is contained in:
G. Gibson 2026-09-02 00:19:20 -07:00
commit 6250104ff5
2 changed files with 105 additions and 95 deletions

View file

@ -11,6 +11,18 @@
# Politeness: audio/video verdict cached in shows.media_class; gpodder OPML # Politeness: audio/video verdict cached in shows.media_class; gpodder OPML
# pull uses bounded retry with exponential backoff + jitter. # pull uses bounded retry with exponential backoff + jitter.
# ---------------------------------------------------------------------------
# Gem path bootstrap: pin GEM_HOME/GEM_PATH before any require so the script
# finds its gems regardless of how it was launched (sudo strips them by
# default via env_reset). Derived from this file's own location so the
# scripts are relocatable. Guards prevent overriding an explicit environment.
# ---------------------------------------------------------------------------
SCRIPT_DIR = File.expand_path(File.dirname(__FILE__))
GEMS_DIR = File.join(SCRIPT_DIR, ".gems")
ENV["GEM_HOME"] = GEMS_DIR unless ENV["GEM_HOME"]
ENV["GEM_PATH"] = GEMS_DIR unless ENV["GEM_PATH"]
Gem.paths = { "GEM_HOME" => ENV["GEM_HOME"], "GEM_PATH" => ENV["GEM_PATH"] }
require "sequel" require "sequel"
require "net/http" require "net/http"
require "openssl" require "openssl"
@ -23,7 +35,7 @@ require "base64"
require "securerandom" require "securerandom"
require "rexml/document" require "rexml/document"
ROOT = File.expand_path(File.dirname(__FILE__)) ROOT = SCRIPT_DIR
CONFIG_PATH = File.join(ROOT, "config.json") CONFIG_PATH = File.join(ROOT, "config.json")
AUDIO_EXTS = [".mp3", ".m4a"].freeze AUDIO_EXTS = [".mp3", ".m4a"].freeze
@ -97,31 +109,43 @@ def release_lock!
end end
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Schema: single source of truth, applied idempotently via Sequel. # Schema: single source of truth, applied idempotently via raw SQL through
# Database#execute. Raw DDL avoids the Sequel create_table/alter_table DSL,
# which is unreliable under JRuby (instance_exec'd generator methods can
# resolve to nil). Works identically on CRuby and JDBC.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SHOWS_COLUMNS = { SHOWS_SQL = <<~SQL.freeze
slug: { type: :string, primary_key: true }, CREATE TABLE IF NOT EXISTS shows (
guid: { type: :string, null: false, unique: true }, slug TEXT PRIMARY KEY,
name: { type: :string, null: false }, guid TEXT NOT NULL UNIQUE,
feed_url: { type: :string, null: false, unique: true }, name TEXT NOT NULL,
source: { type: :string, default: "manual" }, feed_url TEXT NOT NULL UNIQUE,
opml_import: { type: :integer, default: 0 }, source TEXT DEFAULT 'manual',
archived: { type: :integer, default: 1 }, opml_import INTEGER DEFAULT 0,
media_class: { type: :string }, archived INTEGER DEFAULT 1,
created_at: { type: :string } media_class TEXT,
}.freeze created_at TEXT
);
SQL
EPISODES_COLUMNS = { EPISODES_SQL = <<~SQL.freeze
id: { type: :integer, primary_key: true, auto_increment: true }, CREATE TABLE IF NOT EXISTS episodes (
show_slug: { type: :string, null: false }, id INTEGER PRIMARY KEY AUTOINCREMENT,
guid: { type: :string, null: false }, show_slug TEXT NOT NULL,
title: { type: :string }, guid TEXT NOT NULL,
file_path: { type: :string }, title TEXT,
enclosure_url: { type: :string }, file_path TEXT,
runlength: { type: :integer }, enclosure_url TEXT,
played: { type: :integer, default: 0 }, runlength INTEGER,
played_at: { type: :string } played INTEGER DEFAULT 0,
}.freeze played_at TEXT,
UNIQUE (show_slug, guid)
);
SQL
INDEX_EPISODES_SQL = <<~SQL.freeze
CREATE INDEX IF NOT EXISTS idx_episodes_show_played ON episodes (show_slug, played);
SQL
def tune(db) def tune(db)
# Plain-SQL pragmas via Database#execute, which works on CRuby and JRuby/JDBC # Plain-SQL pragmas via Database#execute, which works on CRuby and JRuby/JDBC
@ -134,37 +158,18 @@ end
def connect_subs def connect_subs
db = Sequel.connect("jdbc:sqlite:#{$subs_db_path}") db = Sequel.connect("jdbc:sqlite:#{$subs_db_path}")
tune(db) tune(db)
ensure_schema!(db, :shows, SHOWS_COLUMNS) db.execute(SHOWS_SQL)
db db
end end
def connect_played def connect_played
db = Sequel.connect("jdbc:sqlite:#{$played_db_path}") db = Sequel.connect("jdbc:sqlite:#{$played_db_path}")
tune(db) tune(db)
ensure_schema!(db, :episodes, EPISODES_COLUMNS) db.execute(EPISODES_SQL)
unless db.index_exists?(:episodes, [:show_slug, :played]) db.execute(INDEX_EPISODES_SQL)
db.create_index :episodes, [:show_slug, :played], name: :idx_episodes_show_played
end
db db
end end
def ensure_schema!(db, table, columns)
unless db.table_exists?(table)
db.create_table(table) do |t|
columns.each do |col, opts|
t.column(col, **opts)
end
t.unique_constraint %i[show_slug guid] if table == :episodes
end
return
end
existing = db.columns(table)
columns.each do |col, opts|
next if existing.include?(col)
db.alter_table(table) { |t| t.add_column(col, **opts) }
end
end
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -187,8 +192,8 @@ def classify_feed(entries)
return "audio" if mime.start_with?("audio/") return "audio" if mime.start_with?("audio/")
return "video" if mime.start_with?("video/") return "video" if mime.start_with?("video/")
url = (enclosures.first[:href] || "").downcase url = (enclosures.first[:href] || "").downcase
AUDIO_EXTS.any? { |ext| url.end_with?(ext) } && return "audio" return "audio" if AUDIO_EXTS.any? { |ext| url.end_with?(ext) }
VIDEO_EXTS.any? { |ext| url.end_with?(ext) } && return "video" return "video" if VIDEO_EXTS.any? { |ext| url.end_with?(ext) }
"unknown" "unknown"
end end

View file

@ -7,12 +7,24 @@
# Shares the same exclusive lockfile as fetch_podcasts.rb; skips cleanly if # Shares the same exclusive lockfile as fetch_podcasts.rb; skips cleanly if
# the fetcher holds it. Databases run in WAL mode with a busy timeout. # the fetcher holds it. Databases run in WAL mode with a busy timeout.
# ---------------------------------------------------------------------------
# Gem path bootstrap: pin GEM_HOME/GEM_PATH before any require so the script
# finds its gems regardless of how it was launched (sudo strips them by
# default via env_reset). Derived from this file's own location so the
# scripts are relocatable. Guards prevent overriding an explicit environment.
# ---------------------------------------------------------------------------
SCRIPT_DIR = File.expand_path(File.dirname(__FILE__))
GEMS_DIR = File.join(SCRIPT_DIR, ".gems")
ENV["GEM_HOME"] = GEMS_DIR unless ENV["GEM_HOME"]
ENV["GEM_PATH"] = GEMS_DIR unless ENV["GEM_PATH"]
Gem.paths = { "GEM_HOME" => ENV["GEM_HOME"], "GEM_PATH" => ENV["GEM_PATH"] }
require "sequel" require "sequel"
require "json" require "json"
require "logger" require "logger"
require "time" require "time"
ROOT = File.expand_path(File.dirname(__FILE__)) ROOT = SCRIPT_DIR
CONFIG_PATH = File.join(ROOT, "config.json") CONFIG_PATH = File.join(ROOT, "config.json")
$state_dir = nil $state_dir = nil
@ -52,31 +64,43 @@ def setup_logging!
end end
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Schema: single source of truth, applied idempotently via Sequel. # Schema: single source of truth, applied idempotently via raw SQL through
# Database#execute. Raw DDL avoids the Sequel create_table/alter_table DSL,
# which is unreliable under JRuby (instance_exec'd generator methods can
# resolve to nil). Works identically on CRuby and JDBC.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SHOWS_COLUMNS = { SHOWS_SQL = <<~SQL.freeze
slug: { type: :string, primary_key: true }, CREATE TABLE IF NOT EXISTS shows (
guid: { type: :string, null: false, unique: true }, slug TEXT PRIMARY KEY,
name: { type: :string, null: false }, guid TEXT NOT NULL UNIQUE,
feed_url: { type: :string, null: false, unique: true }, name TEXT NOT NULL,
source: { type: :string, default: "manual" }, feed_url TEXT NOT NULL UNIQUE,
opml_import: { type: :integer, default: 0 }, source TEXT DEFAULT 'manual',
archived: { type: :integer, default: 1 }, opml_import INTEGER DEFAULT 0,
media_class: { type: :string }, archived INTEGER DEFAULT 1,
created_at: { type: :string } media_class TEXT,
}.freeze created_at TEXT
);
SQL
EPISODES_COLUMNS = { EPISODES_SQL = <<~SQL.freeze
id: { type: :integer, primary_key: true, auto_increment: true }, CREATE TABLE IF NOT EXISTS episodes (
show_slug: { type: :string, null: false }, id INTEGER PRIMARY KEY AUTOINCREMENT,
guid: { type: :string, null: false }, show_slug TEXT NOT NULL,
title: { type: :string }, guid TEXT NOT NULL,
file_path: { type: :string }, title TEXT,
enclosure_url: { type: :string }, file_path TEXT,
runlength: { type: :integer }, enclosure_url TEXT,
played: { type: :integer, default: 0 }, runlength INTEGER,
played_at: { type: :string } played INTEGER DEFAULT 0,
}.freeze played_at TEXT,
UNIQUE (show_slug, guid)
);
SQL
INDEX_EPISODES_SQL = <<~SQL.freeze
CREATE INDEX IF NOT EXISTS idx_episodes_show_played ON episodes (show_slug, played);
SQL
def tune(db) def tune(db)
# Plain-SQL pragmas via Database#execute, which works on CRuby and JRuby/JDBC # Plain-SQL pragmas via Database#execute, which works on CRuby and JRuby/JDBC
@ -89,37 +113,18 @@ end
def connect_subs def connect_subs
db = Sequel.connect("jdbc:sqlite:#{$subs_db_path}") db = Sequel.connect("jdbc:sqlite:#{$subs_db_path}")
tune(db) tune(db)
ensure_schema!(db, :shows, SHOWS_COLUMNS) db.execute(SHOWS_SQL)
db db
end end
def connect_played def connect_played
db = Sequel.connect("jdbc:sqlite:#{$played_db_path}") db = Sequel.connect("jdbc:sqlite:#{$played_db_path}")
tune(db) tune(db)
ensure_schema!(db, :episodes, EPISODES_COLUMNS) db.execute(EPISODES_SQL)
unless db.index_exists?(:episodes, [:show_slug, :played]) db.execute(INDEX_EPISODES_SQL)
db.create_index :episodes, [:show_slug, :played], name: :idx_episodes_show_played
end
db db
end end
def ensure_schema!(db, table, columns)
unless db.table_exists?(table)
db.create_table(table) do |t|
columns.each do |col, opts|
t.column(col, **opts)
end
t.unique_constraint %i[show_slug guid] if table == :episodes
end
return
end
existing = db.columns(table)
columns.each do |col, opts|
next if existing.include?(col)
db.alter_table(table) { |t| t.add_column(col, **opts) }
end
end
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Mutual exclusion via flock on a shared lockfile. # Mutual exclusion via flock on a shared lockfile.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------