mirror of
https://github.com/mistergibson/radio.git
synced 2026-09-09 06:19:52 -07:00
storage dir setup
This commit is contained in:
parent
952c9ae5a9
commit
1b191b868d
9 changed files with 418 additions and 402 deletions
35
.gitignore
vendored
35
.gitignore
vendored
|
|
@ -1,37 +1,20 @@
|
||||||
# Local Ruby gems (installed by install_for_jruby)
|
# Secrets and local configuration
|
||||||
.gems/
|
config.json
|
||||||
|
|
||||||
# Python virtualenv
|
# Runtime data (should live under the storage path, not here)
|
||||||
.venv/
|
|
||||||
|
|
||||||
# Runtime state: SQLite DBs and journals
|
|
||||||
state/
|
state/
|
||||||
*.db
|
|
||||||
*.db-journal
|
|
||||||
*.sqlite3
|
|
||||||
*.sqlite3-journal
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs/
|
logs/
|
||||||
|
playlists/
|
||||||
|
*.db
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Large / regenerable media directories
|
# Downloaded media (belongs under the storage path)
|
||||||
music/
|
|
||||||
podcasts/
|
podcasts/
|
||||||
|
music/
|
||||||
jingles/
|
jingles/
|
||||||
announcements/
|
announcements/
|
||||||
|
|
||||||
# Generated playlist files
|
# OS cruft
|
||||||
playlists/
|
|
||||||
*.pls
|
|
||||||
|
|
||||||
# Secrets
|
|
||||||
config.json
|
|
||||||
|
|
||||||
# OS / editor noise
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
*.swp
|
|
||||||
*~
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
|
|
|
||||||
107
README.md
107
README.md
|
|
@ -1,5 +1,112 @@
|
||||||
[](https://firstdonoharm.dev/version/3/0/full.html)
|
[](https://firstdonoharm.dev/version/3/0/full.html)
|
||||||
|
|
||||||
|
# Radio Automation
|
||||||
|
|
||||||
|
Self-hosted internet radio built on **liquidsoap** feeding an **Icecast**
|
||||||
|
server, with podcast subscription management via gPodder.net or manual feeds.
|
||||||
|
|
||||||
|
Two parallel implementations are provided:
|
||||||
|
|
||||||
|
- **Python** (`fetch_podcasts.py`, `update_playlists.py`) — CPython 3, uses
|
||||||
|
`feedparser` and `requests`.
|
||||||
|
- **JRuby** (`fetch_podcasts.rb`, `update_playlists.rb`) — JRuby 9.x/10.x,
|
||||||
|
uses only stdlib plus the `sqlite3` gem.
|
||||||
|
|
||||||
|
Pick whichever you prefer; both produce identical behaviour and share the
|
||||||
|
same `config.json` and `station.liq`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
Everything that grows over time (downloaded podcasts, music, jingles,
|
||||||
|
announcements, state databases, logs, generated playlists) lives under a
|
||||||
|
single **storage path** chosen at install time and recorded in
|
||||||
|
`config.json`. The code itself stays in this directory.
|
||||||
|
|
||||||
|
<storage>/
|
||||||
|
music/ # background music library (recursive mp3/m4a scan)
|
||||||
|
podcasts/<slug>/ # downloaded episodes, one folder per show
|
||||||
|
jingles/ # optional bumpers
|
||||||
|
announcements/ # optional station IDs
|
||||||
|
state/
|
||||||
|
subscriptions.db
|
||||||
|
played.db
|
||||||
|
playlists/ # generated per-show .pls files
|
||||||
|
logs/ # fetch.log, update.log, liquidsoap.log, cron.log
|
||||||
|
|
||||||
|
The install directory contains only code and configuration:
|
||||||
|
|
||||||
|
fetch_podcasts.py / .rb
|
||||||
|
update_playlists.py / .rb
|
||||||
|
station.liq
|
||||||
|
install_for_python.sh / install_for_jruby.sh
|
||||||
|
config.json # created by the installer (never committed)
|
||||||
|
schedule.txt # cron-style scheduled shows (you author this)
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
Run the installer matching your runtime:
|
||||||
|
|
||||||
|
sudo ./install_for_python.sh
|
||||||
|
# or
|
||||||
|
sudo ./install_for_jruby.sh
|
||||||
|
|
||||||
|
You will be prompted for:
|
||||||
|
|
||||||
|
1. **Storage path** — where all media and state live. Defaults to
|
||||||
|
`/srv/radio-storage`. Choose a disk with plenty of free space; this is
|
||||||
|
what keeps downloads off your boot drive.
|
||||||
|
2. **Icecast** source port, mount point, username, and password.
|
||||||
|
3. Optionally, **gPodder.net** credentials for automatic subscription sync.
|
||||||
|
|
||||||
|
The installer creates the directory tree, writes `config.json` (mode 600),
|
||||||
|
installs the `liquidsoap` systemd unit named after this directory, and adds
|
||||||
|
two cron jobs:
|
||||||
|
|
||||||
|
- Hourly at minute 0: fetch new podcast episodes.
|
||||||
|
- Hourly at minute 30: regenerate per-show playlists from playback history.
|
||||||
|
|
||||||
|
Start the station with:
|
||||||
|
|
||||||
|
systemctl start <directory-name>
|
||||||
|
|
||||||
|
## Managing shows
|
||||||
|
|
||||||
|
List registered shows:
|
||||||
|
|
||||||
|
python3 fetch_podcasts.py --list-shows --detail
|
||||||
|
# or
|
||||||
|
jruby fetch_podcasts.rb --list-shows --detail
|
||||||
|
|
||||||
|
Add a show directly from a feed URL:
|
||||||
|
|
||||||
|
python3 fetch_podcasts.py --add-show https://example.com/feed.xml
|
||||||
|
|
||||||
|
Delete a show and its downloaded data:
|
||||||
|
|
||||||
|
python3 fetch_podcasts.py --delete-show some_slug
|
||||||
|
|
||||||
|
Import an OPML export (e.g. from gpodder):
|
||||||
|
|
||||||
|
python3 fetch_podcasts.py --import-opml ~/gpodder_export.opml
|
||||||
|
|
||||||
|
Shows imported via OPML are flagged and protected from pruning during
|
||||||
|
gPodder sync.
|
||||||
|
|
||||||
|
## Playback model
|
||||||
|
|
||||||
|
Background music plays continuously. Scheduled shows interrupt the music at
|
||||||
|
their appointed times; each scheduled entry in `schedule.txt` needs a
|
||||||
|
runlength so the scheduler knows when to hand control back to the music bed.
|
||||||
|
Episode runlengths are extracted from RSS enclosure data when available.
|
||||||
|
|
||||||
|
Playlists recursively scan nested directories for `.mp3` and `.m4a` files,
|
||||||
|
so you can organise your music however you like.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Gems for JRuby should be installed one at a time (separate `gem install`
|
||||||
|
commands); bundling several in one command can hit memory limits.
|
||||||
|
- `config.json` is gitignored and contains secrets — keep it mode 600.
|
||||||
# Radio Automation Stack
|
# Radio Automation Stack
|
||||||
|
|
||||||
A self-hosted internet radio automation system for Linux Mint 22.3 (Ubuntu 24.04 Noble). It continuously plays background music, interrupts it with scheduled podcast shows and external streams, downloads new podcast episodes from RSS feeds, and manages playback history — all fed to an Icecast server via liquidsoap.
|
A self-hosted internet radio automation system for Linux Mint 22.3 (Ubuntu 24.04 Noble). It continuously plays background music, interrupts it with scheduled podcast shows and external streams, downloads new podcast episodes from RSS feeds, and manages playback history — all fed to an Icecast server via liquidsoap.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
"""
|
"""
|
||||||
fetch_podcasts.py - Podcast subscription management and episode fetching
|
fetch_podcasts.py - Podcast subscription management and episode fetching
|
||||||
for the liquidsoap radio automation stack.
|
for the liquidsoap radio automation stack.
|
||||||
|
|
||||||
|
All data (podcasts, state DBs, logs, playlists) lives under the storage path
|
||||||
|
defined in config.json ("storage" key), keeping the boot drive clean.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
@ -20,19 +23,36 @@ import requests
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
CONFIG_PATH = ROOT / "config.json"
|
CONFIG_PATH = ROOT / "config.json"
|
||||||
STATE_DIR = ROOT / "state"
|
|
||||||
SUBS_DB = STATE_DIR / "subscriptions.db"
|
|
||||||
PLAYED_DB = STATE_DIR / "played.db"
|
|
||||||
PODCASTS_DIR = ROOT / "podcasts"
|
|
||||||
LOGS_DIR = ROOT / "logs"
|
|
||||||
|
|
||||||
AUDIO_EXTS = {".mp3", ".m4a"}
|
AUDIO_EXTS = {".mp3", ".m4a"}
|
||||||
|
|
||||||
|
STATE_DIR = None
|
||||||
|
SUBS_DB = None
|
||||||
|
PLAYED_DB = None
|
||||||
|
PODCASTS_DIR = None
|
||||||
|
LOGS_DIR = None
|
||||||
|
PLAYLISTS_DIR = None
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def init_paths():
|
||||||
|
"""Resolve all data paths from config.json storage key."""
|
||||||
|
global STATE_DIR, SUBS_DB, PLAYED_DB, PODCASTS_DIR, LOGS_DIR, PLAYLISTS_DIR
|
||||||
|
cfg = load_config()
|
||||||
|
storage = Path(cfg["storage"]).expanduser().resolve()
|
||||||
|
STATE_DIR = storage / "state"
|
||||||
|
SUBS_DB = STATE_DIR / "subscriptions.db"
|
||||||
|
PLAYED_DB = STATE_DIR / "played.db"
|
||||||
|
PODCASTS_DIR = storage / "podcasts"
|
||||||
|
LOGS_DIR = storage / "logs"
|
||||||
|
PLAYLISTS_DIR = storage / "playlists"
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
handlers=[
|
handlers=[
|
||||||
logging.FileHandler(LOGS_DIR / "fetch.log"),
|
|
||||||
logging.StreamHandler(sys.stdout),
|
logging.StreamHandler(sys.stdout),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
@ -41,9 +61,11 @@ log = logging.getLogger("fetch_podcasts")
|
||||||
def log_error(msg):
|
def log_error(msg):
|
||||||
log.error(msg)
|
log.error(msg)
|
||||||
|
|
||||||
def load_config():
|
def _setup_logging():
|
||||||
with open(CONFIG_PATH) as f:
|
"""Add file handler once LOGS_DIR is known."""
|
||||||
return json.load(f)
|
fh = logging.FileHandler(LOGS_DIR / "fetch.log")
|
||||||
|
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
||||||
|
log.addHandler(fh)
|
||||||
|
|
||||||
def open_subs_db():
|
def open_subs_db():
|
||||||
conn = sqlite3.connect(SUBS_DB)
|
conn = sqlite3.connect(SUBS_DB)
|
||||||
|
|
@ -313,7 +335,7 @@ def remove_show_data(slug):
|
||||||
pod_dir = PODCASTS_DIR / slug
|
pod_dir = PODCASTS_DIR / slug
|
||||||
if pod_dir.exists():
|
if pod_dir.exists():
|
||||||
shutil.rmtree(pod_dir)
|
shutil.rmtree(pod_dir)
|
||||||
pls = ROOT / "playlists" / f"{slug}.pls"
|
pls = PLAYLISTS_DIR / f"{slug}.pls"
|
||||||
if pls.exists():
|
if pls.exists():
|
||||||
pls.unlink()
|
pls.unlink()
|
||||||
|
|
||||||
|
|
@ -373,9 +395,12 @@ def main():
|
||||||
parser.add_argument("--import-opml", metavar="FILE", help="Import shows from an OPML file")
|
parser.add_argument("--import-opml", metavar="FILE", help="Import shows from an OPML file")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
STATE_DIR.mkdir(exist_ok=True)
|
init_paths()
|
||||||
LOGS_DIR.mkdir(exist_ok=True)
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
PODCASTS_DIR.mkdir(exist_ok=True)
|
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
PODCASTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
PLAYLISTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
_setup_logging()
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,29 @@ require "optparse"
|
||||||
module RadioAutomation
|
module RadioAutomation
|
||||||
ROOT = File.expand_path("..", __dir__)
|
ROOT = File.expand_path("..", __dir__)
|
||||||
CONFIG_PATH = File.join(ROOT, "config.json")
|
CONFIG_PATH = File.join(ROOT, "config.json")
|
||||||
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")
|
|
||||||
LOGS_DIR = File.join(ROOT, "logs")
|
|
||||||
AUDIO_EXTS = [".mp3", ".m4a"]
|
AUDIO_EXTS = [".mp3", ".m4a"]
|
||||||
|
|
||||||
|
# Resolved at runtime from config.json "storage" key
|
||||||
|
STORAGE_DIR = nil
|
||||||
|
STATE_DIR = nil
|
||||||
|
SUBS_DB = nil
|
||||||
|
PLAYED_DB = nil
|
||||||
|
PODCASTS_DIR = nil
|
||||||
|
LOGS_DIR = nil
|
||||||
|
PLAYLISTS_DIR = nil
|
||||||
|
|
||||||
|
def self.init_paths
|
||||||
|
cfg = JSON.parse(File.read(CONFIG_PATH))
|
||||||
|
@storage = File.expand_path(cfg["storage"])
|
||||||
|
self.STORAGE_DIR = @storage
|
||||||
|
self.STATE_DIR = File.join(@storage, "state")
|
||||||
|
self.SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
|
||||||
|
self.PLAYED_DB = File.join(STATE_DIR, "played.db")
|
||||||
|
self.PODCASTS_DIR = File.join(@storage, "podcasts")
|
||||||
|
self.LOGS_DIR = File.join(@storage, "logs")
|
||||||
|
self.PLAYLISTS_DIR = File.join(@storage, "playlists")
|
||||||
|
end
|
||||||
|
|
||||||
def self.log_info(msg)
|
def self.log_info(msg)
|
||||||
puts "#{Time.now.iso8601} [INFO] #{msg}"
|
puts "#{Time.now.iso8601} [INFO] #{msg}"
|
||||||
append_log("fetch.log", msg)
|
append_log("fetch.log", msg)
|
||||||
|
|
@ -47,9 +63,6 @@ module RadioAutomation
|
||||||
s[0, 60] || "show"
|
s[0, 60] || "show"
|
||||||
end
|
end
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
# Database
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
def self.open_subs_db
|
def self.open_subs_db
|
||||||
db = SQLite3::Database.new(SUBS_DB)
|
db = SQLite3::Database.new(SUBS_DB)
|
||||||
db.results_as_hash = true
|
db.results_as_hash = true
|
||||||
|
|
@ -84,15 +97,11 @@ module RadioAutomation
|
||||||
db
|
db
|
||||||
end
|
end
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
# gPodder.net sync
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
def self.gpodder_sync(config)
|
def self.gpodder_sync(config)
|
||||||
g = config["gpodder"]
|
g = config["gpodder"]
|
||||||
base = g["host"].chomp("/")
|
base = g["host"].chomp("/")
|
||||||
username = g["username"]
|
username = g["username"]
|
||||||
password = g["password"]
|
password = g["password"]
|
||||||
|
|
||||||
url = "#{base}/subscriptions/#{CGI.escape(username)}.opml"
|
url = "#{base}/subscriptions/#{CGI.escape(username)}.opml"
|
||||||
|
|
||||||
puts "--- Syncing subscriptions from #{base} ---"
|
puts "--- Syncing subscriptions from #{base} ---"
|
||||||
|
|
@ -184,11 +193,7 @@ module RadioAutomation
|
||||||
removed
|
removed
|
||||||
end
|
end
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
# Feed parsing and download
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
def self.extract_duration(entry_xml)
|
def self.extract_duration(entry_xml)
|
||||||
# Try media:duration first
|
|
||||||
if (m = entry_xml.match(/media:duration[^>]*content="([^"]+)"/))
|
if (m = entry_xml.match(/media:duration[^>]*content="([^"]+)"/))
|
||||||
val = m[1]
|
val = m[1]
|
||||||
return val.to_i if val =~ /\A\d+\z/
|
return val.to_i if val =~ /\A\d+\z/
|
||||||
|
|
@ -197,7 +202,6 @@ module RadioAutomation
|
||||||
return (h || 0) * 3600 + (mn || 0) * 60 + (s || 0)
|
return (h || 0) * 3600 + (mn || 0) * 60 + (s || 0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
# Fall back to enclosure length (bytes) -> rough seconds at 128kbps
|
|
||||||
if (m = entry_xml.match(/enclosure[^>]*length="(\d+)"/))
|
if (m = entry_xml.match(/enclosure[^>]*length="(\d+)"/))
|
||||||
bytes = m[1].to_i
|
bytes = m[1].to_i
|
||||||
return (bytes * 8 / 128_000) if bytes > 0
|
return (bytes * 8 / 128_000) if bytes > 0
|
||||||
|
|
@ -266,7 +270,6 @@ module RadioAutomation
|
||||||
subs_db = open_subs_db
|
subs_db = open_subs_db
|
||||||
new_count = 0
|
new_count = 0
|
||||||
|
|
||||||
# Simple regex-based RSS/Atom entry extraction
|
|
||||||
raw.scan(/<(?:item|entry)[^>]*>(.*?)<\/(?:item|entry)>/mi).each do |(entry_xml)|
|
raw.scan(/<(?:item|entry)[^>]*>(.*?)<\/(?:item|entry)>/mi).each do |(entry_xml)|
|
||||||
guid_m = entry_xml.match(/<guid[^>]*>([^<]*)<\/guid>|<id>([^<]*)<\/id>|<link[^>]*href="([^"]+)"/i)
|
guid_m = entry_xml.match(/<guid[^>]*>([^<]*)<\/guid>|<id>([^<]*)<\/id>|<link[^>]*href="([^"]+)"/i)
|
||||||
guid = guid_m ? (guid_m[1] || guid_m[2] || guid_m[3]).strip : Digest::MD5.hexdigest(entry_xml[0, 200])
|
guid = guid_m ? (guid_m[1] || guid_m[2] || guid_m[3]).strip : Digest::MD5.hexdigest(entry_xml[0, 200])
|
||||||
|
|
@ -312,9 +315,6 @@ module RadioAutomation
|
||||||
log_info("=== Fetch complete: #{total_new} new episode(s) ===")
|
log_info("=== Fetch complete: #{total_new} new episode(s) ===")
|
||||||
end
|
end
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
# Admin operations
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
def self.list_shows(detail: false)
|
def self.list_shows(detail: false)
|
||||||
db = open_subs_db
|
db = open_subs_db
|
||||||
rows = db.query_all("SELECT slug, name, feed_url, source, opml_import FROM shows ORDER BY name")
|
rows = db.query_all("SELECT slug, name, feed_url, source, opml_import FROM shows ORDER BY name")
|
||||||
|
|
@ -359,7 +359,7 @@ module RadioAutomation
|
||||||
def self.remove_show_data(slug)
|
def self.remove_show_data(slug)
|
||||||
pod_dir = File.join(PODCASTS_DIR, slug)
|
pod_dir = File.join(PODCASTS_DIR, slug)
|
||||||
FileUtils.rm_rf(pod_dir) if Dir.exist?(pod_dir)
|
FileUtils.rm_rf(pod_dir) if Dir.exist?(pod_dir)
|
||||||
pls = File.join(ROOT, "playlists", "#{slug}.pls")
|
pls = File.join(PLAYLISTS_DIR, "#{slug}.pls")
|
||||||
File.delete(pls) if File.exist?(pls)
|
File.delete(pls) if File.exist?(pls)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -394,9 +394,6 @@ module RadioAutomation
|
||||||
log_info("OPML import: #{shows.size} show(s) processed.")
|
log_info("OPML import: #{shows.size} show(s) processed.")
|
||||||
end
|
end
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
# Main flow
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
def self.run_fetch(config)
|
def self.run_fetch(config)
|
||||||
g = config["gpodder"]
|
g = config["gpodder"]
|
||||||
if g["enable"] == true
|
if g["enable"] == true
|
||||||
|
|
@ -423,9 +420,11 @@ module RadioAutomation
|
||||||
opts.on("--import-opml FILE", "Import shows from an OPML file") { |v| options[:import] = v }
|
opts.on("--import-opml FILE", "Import shows from an OPML file") { |v| options[:import] = v }
|
||||||
end.parse!
|
end.parse!
|
||||||
|
|
||||||
|
init_paths
|
||||||
FileUtils.mkdir_p(STATE_DIR)
|
FileUtils.mkdir_p(STATE_DIR)
|
||||||
FileUtils.mkdir_p(LOGS_DIR)
|
FileUtils.mkdir_p(LOGS_DIR)
|
||||||
FileUtils.mkdir_p(PODCASTS_DIR)
|
FileUtils.mkdir_p(PODCASTS_DIR)
|
||||||
|
FileUtils.mkdir_p(PLAYLISTS_DIR)
|
||||||
|
|
||||||
config = load_config
|
config = load_config
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,200 +1,113 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# install_for_jruby - Provision the JRuby-based radio automation stack.
|
#
|
||||||
# Detects/reuses existing Java and JRuby; installs gems one at a time.
|
# install_for_jruby.sh - Installs the JRuby-based radio automation stack.
|
||||||
# Must be run as root from within the target directory (e.g. /srv/radio/).
|
# Same layout as the Python installer but invokes jruby for the scripts.
|
||||||
# Idempotent: safe to re-run.
|
# Derives its service name from the containing directory, prompts for a
|
||||||
|
# storage path, writes config.json, creates the systemd unit and cron jobs.
|
||||||
|
#
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
INSTALL_DIR="$(pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
SERVICE_NAME="$(basename "$INSTALL_DIR")"
|
DIR_NAME="$(basename "$SCRIPT_DIR")"
|
||||||
GEM_HOME_LOCAL="$INSTALL_DIR/.gems"
|
SERVICE_NAME="${DIR_NAME}"
|
||||||
JRBURY_VERSION="10.1.1.0"
|
SYSTEMD_UNIT="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
MAVEN_BASE="https://repo1.maven.org/maven2/org/jruby/jruby-dist"
|
CONFIG_FILE="${SCRIPT_DIR}/config.json"
|
||||||
JRUBY_HOME_PINNED="/opt/jruby-${JRBURY_VERSION}"
|
|
||||||
|
|
||||||
echo "=== Radio Automation Installer (JRuby) ==="
|
JRuby_BIN="$(command -v jruby || echo /usr/local/bin/jruby)"
|
||||||
echo "Install dir: $INSTALL_DIR"
|
|
||||||
echo "Service name: $SERVICE_NAME"
|
|
||||||
echo
|
|
||||||
|
|
||||||
# --- Prompt for config values (pre-fill from existing config.json) ---
|
echo "=== ${SERVICE_NAME} installer (JRuby) ==="
|
||||||
EXISTING_CONFIG="$INSTALL_DIR/config.json"
|
echo "Install dir: ${SCRIPT_DIR}"
|
||||||
declare -A CFG
|
echo "Using jruby: ${JRuby_BIN}"
|
||||||
CFG[ICE_HOST]="localhost"
|
echo ""
|
||||||
CFG[ICE_PORT]="7777"
|
|
||||||
CFG[ICE_MOUNT]="/audio.mp3"
|
|
||||||
CFG[ICE_USER]="source"
|
|
||||||
CFG[ICE_PASS]=""
|
|
||||||
CFG[GPODDER_ENABLE]="false"
|
|
||||||
CFG[GPODDER_HOST]="https://gpodder.net"
|
|
||||||
CFG[GPODDER_USER]=""
|
|
||||||
CFG[GPODDER_PASS]=""
|
|
||||||
|
|
||||||
if [[ -f "$EXISTING_CONFIG" ]]; then
|
# --- Prompt for storage path ---------------------------------------------
|
||||||
echo "Existing config.json found; using as defaults."
|
DEFAULT_STORAGE="/srv/radio-storage"
|
||||||
CFG[ICE_HOST]=$(jq -r '.icecast.host // "localhost"' "$EXISTING_CONFIG")
|
read -rp "Enter storage path for media/state/logs [${DEFAULT_STORAGE}]: " STORAGE_PATH
|
||||||
CFG[ICE_PORT]=$(jq -r '.icecast.port // 7777' "$EXISTING_CONFIG")
|
STORAGE_PATH="${STORAGE_PATH:-$DEFAULT_STORAGE}"
|
||||||
CFG[ICE_MOUNT]=$(jq -r '.icecast.mount // "/audio.mp3"' "$EXISTING_CONFIG")
|
mkdir -p "${STORAGE_PATH}"/{music,podcasts,jingles,announcements,state,playlists,logs}
|
||||||
CFG[ICE_USER]=$(jq -r '.icecast.username // "source"' "$EXISTING_CONFIG")
|
chown -R liquidsoap:liquidsoap "${STORAGE_PATH}" 2>/dev/null || true
|
||||||
CFG[ICE_PASS]=$(jq -r '.icecast.password // ""' "$EXISTING_CONFIG")
|
|
||||||
CFG[GPODDER_ENABLE]=$(jq -r '.gpodder.enable // false' "$EXISTING_CONFIG")
|
|
||||||
CFG[GPODDER_HOST]=$(jq -r '.gpodder.host // "https://gpodder.net"' "$EXISTING_CONFIG")
|
|
||||||
CFG[GPODDER_USER]=$(jq -r '.gpodder.username // ""' "$EXISTING_CONFIG")
|
|
||||||
CFG[GPODDER_PASS]=$(jq -r '.gpodder.password // ""' "$EXISTING_CONFIG")
|
|
||||||
fi
|
|
||||||
|
|
||||||
read -rp "Icecast host [$CFG[ICE_HOST]]: " v; CFG[ICE_HOST]=${v:-$CFG[ICE_HOST]}
|
# --- Collect Icecast credentials ------------------------------------------
|
||||||
read -rp "Icecast port [$CFG[ICE_PORT]]: " v; CFG[ICE_PORT]=${v:-$CFG[ICE_PORT]}
|
read -rp "Icecast source port [8000]: " IC_PORT
|
||||||
read -rp "Icecast mount [$CFG[ICE_MOUNT]]: " v; CFG[ICE_MOUNT]=${v:-$CFG[ICE_MOUNT]}
|
IC_PORT="${IC_PORT:-8000}"
|
||||||
read -rp "Icecast source username [$CFG[ICE_USER]]: " v; CFG[ICE_USER]=${v:-$CFG[ICE_USER]}
|
read -rp "Icecast mount [/radio.mp3]: " IC_MOUNT
|
||||||
read -rsp "Icecast source password [$CFG[ICE_PASS]]: "; echo; CFG[ICE_PASS]=${v:-$CFG[ICE_PASS]}
|
IC_MOUNT="${IC_MOUNT:-/radio.mp3}"
|
||||||
read -rp "Enable gPodder sync? (true/false) [$CFG[GPODDER_ENABLE]]: " v; CFG[GPODDER_ENABLE]=${v:-$CFG[GPODDER_ENABLE]}
|
read -rp "Icecast source username [sourceadmin]: " IC_USER
|
||||||
read -rp "gPodder host [$CFG[GPODDER_HOST]]: " v; CFG[GPODDER_HOST]=${v:-$CFG[GPODDER_HOST]}
|
IC_USER="${IC_USER:-sourceadmin}"
|
||||||
read -rp "gPodder username [$CFG[GPODDER_USER]]: " v; CFG[GPODDER_USER]=${v:-$CFG[GPODDER_USER]}
|
read -rsp "Icecast source password: " IC_PASS; echo
|
||||||
read -rsp "gPodder password [$CFG[GPODDER_PASS]]: "; echo; CFG[GPODDER_PASS]=${v:-$CFG[GPODDER_PASS]}
|
IC_PASS="${IC_PASS:?Password required}"
|
||||||
|
|
||||||
echo
|
# --- gPodder sync (optional) ----------------------------------------------
|
||||||
echo "Summary:"
|
read -rp "Enable gPodder.net sync? [y/N]: " GPODDER_ENABLE
|
||||||
echo " Icecast: ${CFG[ICE_HOST]}:${CFG[ICE_PORT]}${CFG[ICE_MOUNT]}"
|
case "${GPODDER_ENABLE,,}" in
|
||||||
echo " gPodder sync: ${CFG[GPODDER_ENABLE]}"
|
y|yes)
|
||||||
read -rp "Proceed? [y/N] " confirm
|
read -rp "gPodder host [https://gpodder.net]: " GP_HOST
|
||||||
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
GP_HOST="${GP_HOST:-https://gpodder.net}"
|
||||||
|
read -rp "gPodder username: " GP_USER
|
||||||
|
read -rsp "gPodder password: " GP_PASS; echo
|
||||||
|
GP_JSON=$(jq -n --arg h "$GP_HOST" --arg u "$GP_USER" --arg p "$GP_PASS" \
|
||||||
|
'{enable:true, host:$h, username:$u, password:$p}')
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
GP_JSON='{"enable":false,"host":"","username":"","password":""}'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
# --- Base packages ---
|
# --- Write config.json -----------------------------------------------------
|
||||||
echo "Installing base packages..."
|
cat > "$CONFIG_FILE" <<EOF
|
||||||
apt-get update -qq
|
|
||||||
apt-get install -y -qq liquidsoap icecast2 jq curl unzip ca-certificates >/dev/null
|
|
||||||
|
|
||||||
# --- Detect or install Java (>= 21) ---
|
|
||||||
JAVA_OK=false
|
|
||||||
if command -v java &>/dev/null; then
|
|
||||||
JAVA_VER=$(java -version 2>&1 | head -1 | sed 's/.*"\([0-9]*\)\..*/\1/')
|
|
||||||
if (( JAVA_VER >= 21 )); then
|
|
||||||
echo "Found Java $JAVA_VER; reusing."
|
|
||||||
JAVA_OK=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if [[ "$JAVA_OK" != "true" ]]; then
|
|
||||||
echo "Installing OpenJDK 21 headless..."
|
|
||||||
apt-get install -y -qq openjdk-21-jdk-headless >/dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Detect or install JRuby ---
|
|
||||||
JRUBY_BIN=""
|
|
||||||
if [[ -x "$JRUBY_HOME_PINNED/bin/jruby" ]]; then
|
|
||||||
JRUBY_BIN="$JRUBY_HOME_PINNED/bin/jruby"
|
|
||||||
echo "Found pinned JRuby at $JRUBY_HOME_PINNED"
|
|
||||||
elif command -v jruby &>/dev/null; then
|
|
||||||
JRUBY_BIN="$(command -v jruby)"
|
|
||||||
echo "Found JRuby on PATH: $JRUBY_BIN"
|
|
||||||
else
|
|
||||||
echo "Downloading JRuby ${JRBURY_VERSION} from Maven Central..."
|
|
||||||
TARBALL="jruby-dist-${JRBURY_VERSION}-bin.tar.gz"
|
|
||||||
URL="${MAVEN_BASE}/${JRBURY_VERSION}/${TARBALL}"
|
|
||||||
cd /tmp
|
|
||||||
curl -fsSL -o "$TARBALL" "$URL"
|
|
||||||
tar xzf "$TARBALL"
|
|
||||||
mv "jruby-${JRBURY_VERSION}" "$JRUBY_HOME_PINNED"
|
|
||||||
rm -f "$TARBALL"
|
|
||||||
JRUBY_BIN="$JRUBY_HOME_PINNED/bin/jruby"
|
|
||||||
echo "Installed JRuby to $JRUBY_HOME_PINNED"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Register via update-alternatives ---
|
|
||||||
for cmd in jruby bundle gem rake irb; do
|
|
||||||
ALT_SRC="$($JRUBY_BIN -e "puts Gem.bindir" 2>/dev/null || echo "$(${JRUBY_BIN%/jruby}${cmd}" --version &>/dev/null && which ${cmd} 2>/dev/null || echo "$JRUBY_HOME_PINNED/bin/${cmd}")")"
|
|
||||||
# Simpler: just link the known bin paths
|
|
||||||
LINK_TARGET="$JRUBY_HOME_PINNED/bin/${cmd}"
|
|
||||||
if [[ -x "$LINK_TARGET" ]]; then
|
|
||||||
ln -sf "$LINK_TARGET" "/usr/local/bin/${cmd}"
|
|
||||||
update-alternatives --install "/usr/local/bin/${cmd}" "${cmd}" "$LINK_TARGET" 100 || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "JRuby integrated via /usr/local/bin symlinks."
|
|
||||||
|
|
||||||
# --- Install gems one at a time (avoid OOM on low-RAM hosts) ---
|
|
||||||
export GEM_HOME="$GEM_HOME_LOCAL"
|
|
||||||
export GEM_PATH="$GEM_HOME_LOCAL"
|
|
||||||
export JRUBY_OPTS="-J-Xmx1g -J-Xss512k"
|
|
||||||
|
|
||||||
echo "Installing bundler..."
|
|
||||||
"$JRUBY_BIN" -S gem install bundler --no-document 2>&1 | tail -1
|
|
||||||
echo "Installing nokogiri..."
|
|
||||||
"$JRUBY_BIN" -S gem install nokogiri --no-document 2>&1 | tail -1
|
|
||||||
echo "Installing sqlite3..."
|
|
||||||
"$JRUBY_BIN" -S gem install sqlite3 --no-document 2>&1 | tail -1
|
|
||||||
echo "Installing json..."
|
|
||||||
"$JRUBY_BIN" -S gem install json --no-document 2>&1 | tail -1
|
|
||||||
|
|
||||||
# --- Create directory structure ---
|
|
||||||
for d in music podcasts jingles announcements playlists state logs; do
|
|
||||||
mkdir -p "$INSTALL_DIR/$d"
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- Generate config.json ---
|
|
||||||
cat > "$INSTALL_DIR/config.json" <<EOF
|
|
||||||
{
|
{
|
||||||
|
"storage": "${STORAGE_PATH}",
|
||||||
"icecast": {
|
"icecast": {
|
||||||
"host": "${CFG[ICE_HOST]}",
|
"host": "127.0.0.1",
|
||||||
"port": ${CFG[ICE_PORT]},
|
"port": ${IC_PORT},
|
||||||
"mount": "${CFG[ICE_MOUNT]}",
|
"mount": "${IC_MOUNT}",
|
||||||
"username": "${CFG[ICE_USER]}",
|
"username": "${IC_USER}",
|
||||||
"password": "${CFG[ICE_PASS]}"
|
"password": "${IC_PASS}"
|
||||||
},
|
},
|
||||||
"gpodder": {
|
"gpodder": ${GP_JSON}
|
||||||
"enable": ${CFG[GPODDER_ENABLE]},
|
|
||||||
"host": "${CFG[GPODDER_HOST]}",
|
|
||||||
"username": "${CFG[GPODDER_USER]}",
|
|
||||||
"password": "${CFG[GPODDER_PASS]}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "$INSTALL_DIR/config.json"
|
chmod 600 "$CONFIG_FILE"
|
||||||
|
echo "Wrote ${CONFIG_FILE}"
|
||||||
|
|
||||||
# --- Ensure liquidsoap user exists ---
|
# --- Gem check (install one at a time to avoid memory limits) --------------
|
||||||
if ! id liquidsoap &>/dev/null; then
|
for gem in sqlite3 json; do
|
||||||
useradd --system --home-dir "$INSTALL_DIR" --shell /usr/sbin/nologin liquidsoap
|
if ! "${JRuby_BIN}" -e "require '${gem}'" >/dev/null 2>&1; then
|
||||||
|
echo "Installing gem: ${gem}"
|
||||||
|
gem install "${gem}"
|
||||||
fi
|
fi
|
||||||
chown -R liquidsoap:liquidsoap "$INSTALL_DIR"
|
done
|
||||||
|
|
||||||
# --- Write systemd service ---
|
# --- Systemd unit ------------------------------------------------------------
|
||||||
cat > /etc/systemd/system/"$SERVICE_NAME".service <<EOF
|
cat > "$SYSTEMD_UNIT" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Liquidsoap radio station ($SERVICE_NAME)
|
Description=${SERVICE_NAME} radio automation (liquidsoap)
|
||||||
After=network.target icecast2.service
|
After=network.target icecast2.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=liquidsoap
|
User=liquidsoap
|
||||||
WorkingDirectory=$INSTALL_DIR
|
WorkingDirectory=${SCRIPT_DIR}
|
||||||
Environment=GEM_HOME=${GEM_HOME_LOCAL}
|
ExecStart=/usr/bin/liquidsoap ${SCRIPT_DIR}/station.liq
|
||||||
Environment=GEM_PATH=${GEM_HOME_LOCAL}
|
|
||||||
Environment=JRUBY_OPTS=-J-Xmx1g -J-Xss512k
|
|
||||||
ExecStart=/usr/bin/liquidsoap $INSTALL_DIR/station.liq
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# --- Set up cron jobs (deduplicated) ---
|
|
||||||
FETCH_CMD="sudo -u liquidsoap env GEM_HOME=${GEM_HOME_LOCAL} GEM_PATH=${GEM_HOME_LOCAL} JRUBY_OPTS=-J-Xmx1g -J-Xss512k /usr/local/bin/jruby -S bundle exec $INSTALL_DIR/fetch_podcasts.rb >> $INSTALL_DIR/logs/cron_fetch.log 2>&1"
|
|
||||||
UPDATE_CMD="sudo -u liquidsoap env GEM_HOME=${GEM_HOME_LOCAL} GEM_PATH=${GEM_HOME_LOCAL} JRUBY_OPTS=-J-Xmx1g -J-Xss512k /usr/local/bin/jruby -S bundle exec $INSTALL_DIR/update_playlists.rb >> $INSTALL_DIR/logs/cron_update.log 2>&1"
|
|
||||||
|
|
||||||
(crontab -l 2>/dev/null | grep -v "fetch_podcasts.rb"; echo "0 * * * * $FETCH_CMD") | crontab -
|
|
||||||
(crontab -l 2>/dev/null | grep -v "update_playlists.rb"; echo "30 * * * * $UPDATE_CMD") | crontab -
|
|
||||||
|
|
||||||
# --- Reload and enable ---
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable "$SERVICE_NAME"
|
systemctl enable "${SERVICE_NAME}"
|
||||||
|
echo "Enabled systemd unit: ${SERVICE_NAME}"
|
||||||
|
|
||||||
echo
|
# --- Cron --------------------------------------------------------------------
|
||||||
|
CRON_FETCH="0 * * * * cd ${SCRIPT_DIR} && ${JRuby_BIN} fetch_podcasts.rb >> ${STORAGE_PATH}/logs/cron.log 2>&1"
|
||||||
|
CRON_PLAYLISTS="30 * * * * cd ${SCRIPT_DIR} && ${JRuby_BIN} update_playlists.rb >> ${STORAGE_PATH}/logs/cron.log 2>&1"
|
||||||
|
|
||||||
|
(crontab -l 2>/dev/null | grep -v "fetch_podcasts.rb\|update_playlists.rb"; echo "$CRON_FETCH"; echo "$CRON_PLAYLISTS") | crontab -
|
||||||
|
echo "Installed cron jobs:"
|
||||||
|
echo " $CRON_FETCH"
|
||||||
|
echo " $CRON_PLAYLISTS"
|
||||||
|
|
||||||
|
echo ""
|
||||||
echo "=== Installation complete ==="
|
echo "=== Installation complete ==="
|
||||||
echo "Next steps:"
|
echo "Storage root: ${STORAGE_PATH}"
|
||||||
echo " 1. Drop MP3/M4A files into $INSTALL_DIR/music/"
|
echo "Start with: systemctl start ${SERVICE_NAME}"
|
||||||
echo " 2. Edit $INSTALL_DIR/schedule.txt with your show schedule"
|
|
||||||
echo " 3. Review $INSTALL_DIR/station.liq"
|
|
||||||
echo " 4. Start services:"
|
|
||||||
echo " sudo systemctl start icecast2"
|
|
||||||
echo " sudo systemctl start $SERVICE_NAME"
|
|
||||||
|
|
|
||||||
|
|
@ -1,142 +1,106 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# install_for_python - Provision the Python-based radio automation stack.
|
#
|
||||||
# Must be run as root from within the target directory (e.g. /srv/radio/).
|
# install_for_python.sh - Installs the Python-based radio automation stack.
|
||||||
# Idempotent: safe to re-run.
|
# Derives its service name from the directory it lives in, prompts for a
|
||||||
|
# storage path (kept out of the boot drive), writes config.json, creates
|
||||||
|
# the systemd unit, and sets up cron entries.
|
||||||
|
#
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
INSTALL_DIR="$(pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
SERVICE_NAME="$(basename "$INSTALL_DIR")"
|
DIR_NAME="$(basename "$SCRIPT_DIR")"
|
||||||
VENV="$INSTALL_DIR/.venv"
|
SERVICE_NAME="${DIR_NAME}"
|
||||||
PYTHON_BIN="$VENV/bin/python"
|
SYSTEMD_UNIT="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
CONFIG_FILE="${SCRIPT_DIR}/config.json"
|
||||||
|
|
||||||
echo "=== Radio Automation Installer (Python) ==="
|
echo "=== ${SERVICE_NAME} installer (Python) ==="
|
||||||
echo "Install dir: $INSTALL_DIR"
|
echo "Install dir: ${SCRIPT_DIR}"
|
||||||
echo "Service name: $SERVICE_NAME"
|
echo ""
|
||||||
echo
|
|
||||||
|
|
||||||
# --- Prompt for config values (pre-fill from existing config.json) ---
|
# --- Prompt for storage path ---------------------------------------------
|
||||||
EXISTING_CONFIG="$INSTALL_DIR/config.json"
|
DEFAULT_STORAGE="/srv/radio-storage"
|
||||||
declare -A CFG
|
read -rp "Enter storage path for media/state/logs [${DEFAULT_STORAGE}]: " STORAGE_PATH
|
||||||
CFG[ICE_HOST]="localhost"
|
STORAGE_PATH="${STORAGE_PATH:-$DEFAULT_STORAGE}"
|
||||||
CFG[ICE_PORT]="7777"
|
mkdir -p "${STORAGE_PATH}"/{music,podcasts,jingles,announcements,state,playlists,logs}
|
||||||
CFG[ICE_MOUNT]="/audio.mp3"
|
chown -R liquidsoap:liquidsoap "${STORAGE_PATH}" 2>/dev/null || true
|
||||||
CFG[ICE_USER]="source"
|
|
||||||
CFG[ICE_PASS]=""
|
|
||||||
CFG[GPODDER_ENABLE]="false"
|
|
||||||
CFG[GPODDER_HOST]="https://gpodder.net"
|
|
||||||
CFG[GPODDER_USER]=""
|
|
||||||
CFG[GPODDER_PASS]=""
|
|
||||||
|
|
||||||
if [[ -f "$EXISTING_CONFIG" ]]; then
|
# --- Collect Icecast credentials ------------------------------------------
|
||||||
echo "Existing config.json found; using as defaults."
|
read -rp "Icecast source port [8000]: " IC_PORT
|
||||||
CFG[ICE_HOST]=$(jq -r '.icecast.host // "localhost"' "$EXISTING_CONFIG")
|
IC_PORT="${IC_PORT:-8000}"
|
||||||
CFG[ICE_PORT]=$(jq -r '.icecast.port // 7777' "$EXISTING_CONFIG")
|
read -rp "Icecast mount [/radio.mp3]: " IC_MOUNT
|
||||||
CFG[ICE_MOUNT]=$(jq -r '.icecast.mount // "/audio.mp3"' "$EXISTING_CONFIG")
|
IC_MOUNT="${IC_MOUNT:-/radio.mp3}"
|
||||||
CFG[ICE_USER]=$(jq -r '.icecast.username // "source"' "$EXISTING_CONFIG")
|
read -rp "Icecast source username [sourceadmin]: " IC_USER
|
||||||
CFG[ICE_PASS]=$(jq -r '.icecast.password // ""' "$EXISTING_CONFIG")
|
IC_USER="${IC_USER:-sourceadmin}"
|
||||||
CFG[GPODDER_ENABLE]=$(jq -r '.gpodder.enable // false' "$EXISTING_CONFIG")
|
read -rsp "Icecast source password: " IC_PASS; echo
|
||||||
CFG[GPODDER_HOST]=$(jq -r '.gpodder.host // "https://gpodder.net"' "$EXISTING_CONFIG")
|
IC_PASS="${IC_PASS:?Password required}"
|
||||||
CFG[GPODDER_USER]=$(jq -r '.gpodder.username // ""' "$EXISTING_CONFIG")
|
|
||||||
CFG[GPODDER_PASS]=$(jq -r '.gpodder.password // ""' "$EXISTING_CONFIG")
|
|
||||||
fi
|
|
||||||
|
|
||||||
read -rp "Icecast host [$CFG[ICE_HOST]]: " v; CFG[ICE_HOST]=${v:-$CFG[ICE_HOST]}
|
# --- gPodder sync (optional) ----------------------------------------------
|
||||||
read -rp "Icecast port [$CFG[ICE_PORT]]: " v; CFG[ICE_PORT]=${v:-$CFG[ICE_PORT]}
|
read -rp "Enable gPodder.net sync? [y/N]: " GPODDER_ENABLE
|
||||||
read -rp "Icecast mount [$CFG[ICE_MOUNT]]: " v; CFG[ICE_MOUNT]=${v:-$CFG[ICE_MOUNT]}
|
case "${GPODDER_ENABLE,,}" in
|
||||||
read -rp "Icecast source username [$CFG[ICE_USER]]: " v; CFG[ICE_USER]=${v:-$CFG[ICE_USER]}
|
y|yes)
|
||||||
read -rsp "Icecast source password [$CFG[ICE_PASS]]: "; echo; CFG[ICE_PASS]=${v:-$CFG[ICE_PASS]}
|
read -rp "gPodder host [https://gpodder.net]: " GP_HOST
|
||||||
read -rp "Enable gPodder sync? (true/false) [$CFG[GPODDER_ENABLE]]: " v; CFG[GPODDER_ENABLE]=${v:-$CFG[GPODDER_ENABLE]}
|
GP_HOST="${GP_HOST:-https://gpodder.net}"
|
||||||
read -rp "gPodder host [$CFG[GPODDER_HOST]]: " v; CFG[GPODDER_HOST]=${v:-$CFG[GPODDER_HOST]}
|
read -rp "gPodder username: " GP_USER
|
||||||
read -rp "gPodder username [$CFG[GPODDER_USER]]: " v; CFG[GPODDER_USER]=${v:-$CFG[GPODDER_USER]}
|
read -rsp "gPodder password: " GP_PASS; echo
|
||||||
read -rsp "gPodder password [$CFG[GPODDER_PASS]]: "; echo; CFG[GPODDER_PASS]=${v:-$CFG[GPODDER_PASS]}
|
GP_JSON=$(jq -n --arg h "$GP_HOST" --arg u "$GP_USER" --arg p "$GP_PASS" \
|
||||||
|
'{enable:true, host:$h, username:$u, password:$p}')
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
GP_JSON='{"enable":false,"host":"","username":"","password":""}'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
echo
|
# --- Write config.json -----------------------------------------------------
|
||||||
echo "Summary:"
|
cat > "$CONFIG_FILE" <<EOF
|
||||||
echo " Icecast: ${CFG[ICE_HOST]}:${CFG[ICE_PORT]}${CFG[ICE_MOUNT]}"
|
|
||||||
echo " gPodder sync: ${CFG[GPODDER_ENABLE]}"
|
|
||||||
read -rp "Proceed? [y/N] " confirm
|
|
||||||
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
|
||||||
|
|
||||||
# --- Install system packages ---
|
|
||||||
echo "Installing system packages..."
|
|
||||||
apt-get update -qq
|
|
||||||
apt-get install -y -qq python3 python3-venv python3-pip jq curl >/dev/null
|
|
||||||
|
|
||||||
# --- Create venv and install Python deps ---
|
|
||||||
if [[ ! -d "$VENV" ]]; then
|
|
||||||
echo "Creating virtualenv at $VENV..."
|
|
||||||
python3 -m venv "$VENV"
|
|
||||||
fi
|
|
||||||
echo "Installing Python dependencies..."
|
|
||||||
"$VENV/bin/pip" install --quiet --upgrade pip
|
|
||||||
"$VENV/bin/pip" install --quiet feedparser requests mutagen
|
|
||||||
|
|
||||||
# --- Create directory structure ---
|
|
||||||
for d in music podcasts jingles announcements playlists state logs; do
|
|
||||||
mkdir -p "$INSTALL_DIR/$d"
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- Generate config.json ---
|
|
||||||
cat > "$INSTALL_DIR/config.json" <<EOF
|
|
||||||
{
|
{
|
||||||
|
"storage": "${STORAGE_PATH}",
|
||||||
"icecast": {
|
"icecast": {
|
||||||
"host": "${CFG[ICE_HOST]}",
|
"host": "127.0.0.1",
|
||||||
"port": ${CFG[ICE_PORT]},
|
"port": ${IC_PORT},
|
||||||
"mount": "${CFG[ICE_MOUNT]}",
|
"mount": "${IC_MOUNT}",
|
||||||
"username": "${CFG[ICE_USER]}",
|
"username": "${IC_USER}",
|
||||||
"password": "${CFG[ICE_PASS]}"
|
"password": "${IC_PASS}"
|
||||||
},
|
},
|
||||||
"gpodder": {
|
"gpodder": ${GP_JSON}
|
||||||
"enable": ${CFG[GPODDER_ENABLE]},
|
|
||||||
"host": "${CFG[GPODDER_HOST]}",
|
|
||||||
"username": "${CFG[GPODDER_USER]}",
|
|
||||||
"password": "${CFG[GPODDER_PASS]}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "$INSTALL_DIR/config.json"
|
chmod 600 "$CONFIG_FILE"
|
||||||
|
echo "Wrote ${CONFIG_FILE}"
|
||||||
|
|
||||||
# --- Ensure liquidsoap user exists ---
|
# --- Dependencies -----------------------------------------------------------
|
||||||
if ! id liquidsoap &>/dev/null; then
|
echo "Installing Python dependencies..."
|
||||||
useradd --system --home-dir "$INSTALL_DIR" --shell /usr/sbin/nologin liquidsoap
|
pip3 install feedparser requests 2>/dev/null || pip install feedparser requests
|
||||||
fi
|
|
||||||
chown -R liquidsoap:liquidsoap "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# --- Write systemd service ---
|
# --- Systemd unit ------------------------------------------------------------
|
||||||
cat > /etc/systemd/system/"$SERVICE_NAME".service <<EOF
|
cat > "$SYSTEMD_UNIT" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Liquidsoap radio station ($SERVICE_NAME)
|
Description=${SERVICE_NAME} radio automation (liquidsoap)
|
||||||
After=network.target icecast2.service
|
After=network.target icecast2.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=liquidsoap
|
User=liquidsoap
|
||||||
WorkingDirectory=$INSTALL_DIR
|
WorkingDirectory=${SCRIPT_DIR}
|
||||||
ExecStart=/usr/bin/liquidsoap $INSTALL_DIR/station.liq
|
ExecStart=/usr/bin/liquidsoap ${SCRIPT_DIR}/station.liq
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# --- Set up cron jobs (deduplicated) ---
|
|
||||||
CRON_FETCH="0 * * * * sudo -u liquidsoap $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py >> $INSTALL_DIR/logs/cron_fetch.log 2>&1"
|
|
||||||
CRON_UPDATE="30 * * * * sudo -u liquidsoap $PYTHON_BIN $INSTALL_DIR/update_playlists.py >> $INSTALL_DIR/logs/cron_update.log 2>&1"
|
|
||||||
|
|
||||||
(crontab -l 2>/dev/null | grep -v "fetch_podcasts.py"; echo "$CRON_FETCH") | crontab -
|
|
||||||
(crontab -l 2>/dev/null | grep -v "update_playlists.py"; echo "$CRON_UPDATE") | crontab -
|
|
||||||
|
|
||||||
# --- Reload and enable ---
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable "$SERVICE_NAME"
|
systemctl enable "${SERVICE_NAME}"
|
||||||
|
echo "Enabled systemd unit: ${SERVICE_NAME}"
|
||||||
|
|
||||||
echo
|
# --- Cron --------------------------------------------------------------------
|
||||||
|
CRON_FETCH="0 * * * * cd ${SCRIPT_DIR} && /usr/bin/python3 fetch_podcasts.py >> ${STORAGE_PATH}/logs/cron.log 2>&1"
|
||||||
|
CRON_PLAYLISTS="30 * * * * cd ${SCRIPT_DIR} && /usr/bin/python3 update_playlists.py >> ${STORAGE_PATH}/logs/cron.log 2>&1"
|
||||||
|
|
||||||
|
(crontab -l 2>/dev/null | grep -v "fetch_podcasts.py\|update_playlists.py"; echo "$CRON_FETCH"; echo "$CRON_PLAYLISTS") | crontab -
|
||||||
|
echo "Installed cron jobs:"
|
||||||
|
echo " $CRON_FETCH"
|
||||||
|
echo " $CRON_PLAYLISTS"
|
||||||
|
|
||||||
|
echo ""
|
||||||
echo "=== Installation complete ==="
|
echo "=== Installation complete ==="
|
||||||
echo "Next steps:"
|
echo "Storage root: ${STORAGE_PATH}"
|
||||||
echo " 1. Drop MP3/M4A files into $INSTALL_DIR/music/"
|
echo "Start with: systemctl start ${SERVICE_NAME}"
|
||||||
echo " 2. Edit $INSTALL_DIR/schedule.txt with your show schedule"
|
|
||||||
echo " 3. Review $INSTALL_DIR/station.liq"
|
|
||||||
echo " 4. Start services:"
|
|
||||||
echo " sudo systemctl start icecast2"
|
|
||||||
echo " sudo systemctl start $SERVICE_NAME"
|
|
||||||
|
|
|
||||||
20
station.liq
20
station.liq
|
|
@ -3,16 +3,18 @@
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# station.liq - Liquidsoap configuration for radio automation
|
# station.liq - Liquidsoap configuration for radio automation
|
||||||
# Auto-detects its own location so all paths are relative.
|
# Auto-detects its own location so all paths are relative.
|
||||||
|
# Music/jingles/announcements live under the storage path from
|
||||||
|
# config.json; code stays in the install dir.
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
configure.bindir()
|
configure.bindir()
|
||||||
|
|
||||||
set("log.file.path", "#{bindir()}/logs/liquidsoap.log")
|
|
||||||
set("log.stdout", true)
|
set("log.stdout", true)
|
||||||
set("log.level", 3)
|
set("log.level", 3)
|
||||||
|
|
||||||
# --- Load Icecast credentials from config.json ---
|
# --- Load Icecast credentials + storage path from config.json ---
|
||||||
let json.parse (cfg : {
|
let cfg : {
|
||||||
|
storage: string,
|
||||||
icecast: {
|
icecast: {
|
||||||
host: string,
|
host: string,
|
||||||
port: int,
|
port: int,
|
||||||
|
|
@ -26,22 +28,24 @@ let json.parse (cfg : {
|
||||||
username: string,
|
username: string,
|
||||||
password: string
|
password: string
|
||||||
}
|
}
|
||||||
}) = file.contents("#{bindir()}/config.json")
|
} = json.parse(file.contents("#{bindir()}/config.json"))
|
||||||
|
|
||||||
let ic_host = cfg.icecast.host
|
let ic_host = cfg.icecast.host
|
||||||
let ic_port = cfg.icecast.port
|
let ic_port = cfg.icecast.port
|
||||||
let ic_mount = cfg.icecast.mount
|
let ic_mount = cfg.icecast.mount
|
||||||
let ic_username = cfg.icecast.username
|
let ic_username = cfg.icecast.username
|
||||||
let ic_password = cfg.icecast.password
|
let ic_password = cfg.icecast.password
|
||||||
|
let storage = cfg.storage
|
||||||
|
|
||||||
|
set("log.file.path", "#{storage}/logs/liquidsoap.log")
|
||||||
|
|
||||||
# --- Background music library (recursive scan for mp3/m4a) ---
|
# --- Background music library (recursive scan for mp3/m4a) ---
|
||||||
music_dir = "#{bindir()}/music"
|
|
||||||
music_playlist =
|
music_playlist =
|
||||||
request.cue(
|
request.cue(
|
||||||
playlist(
|
playlist(
|
||||||
recurse=true,
|
recurse=true,
|
||||||
pattern="\\.(mp3|m4a)$",
|
pattern="\\.(mp3|m4a)$",
|
||||||
"#{music_dir}"
|
"#{storage}/music"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -49,8 +53,8 @@ music_playlist =
|
||||||
sched_queue = request.queue(id="scheduler")
|
sched_queue = request.queue(id="scheduler")
|
||||||
|
|
||||||
# --- Jingles / announcements (optional) ---
|
# --- Jingles / announcements (optional) ---
|
||||||
jingle_dir = "#{bindir()}/jingles"
|
jingle_dir = "#{storage}/jingles"
|
||||||
announce_dir = "#{bindir()}/announcements"
|
announce_dir = "#{storage}/announcements"
|
||||||
|
|
||||||
def has_audio(dir) =
|
def has_audio(dir) =
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,57 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
update_playlists.py - Regenerate per-show .pls playlists based on playback history.
|
update_playlists.py - Regenerate per-show .pls playlists based on playback history.
|
||||||
Runs via cron hourly at :30.
|
Runs via cron hourly at :30. All data under the storage path from config.json.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
STATE_DIR = ROOT / "state"
|
CONFIG_PATH = ROOT / "config.json"
|
||||||
SUBS_DB = STATE_DIR / "subscriptions.db"
|
|
||||||
PLAYED_DB = STATE_DIR / "played.db"
|
|
||||||
PODCASTS_DIR = ROOT / "podcasts"
|
|
||||||
PLAYLISTS_DIR = ROOT / "playlists"
|
|
||||||
LOGS_DIR = ROOT / "logs"
|
|
||||||
|
|
||||||
AUDIO_EXTS = {".mp3", ".m4a"}
|
AUDIO_EXTS = {".mp3", ".m4a"}
|
||||||
|
|
||||||
|
STATE_DIR = None
|
||||||
|
SUBS_DB = None
|
||||||
|
PLAYED_DB = None
|
||||||
|
PODCASTS_DIR = None
|
||||||
|
PLAYLISTS_DIR = None
|
||||||
|
LOGS_DIR = None
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def init_paths():
|
||||||
|
global STATE_DIR, SUBS_DB, PLAYED_DB, PODCASTS_DIR, PLAYLISTS_DIR, LOGS_DIR
|
||||||
|
cfg = load_config()
|
||||||
|
storage = Path(cfg["storage"]).expanduser().resolve()
|
||||||
|
STATE_DIR = storage / "state"
|
||||||
|
SUBS_DB = STATE_DIR / "subscriptions.db"
|
||||||
|
PLAYED_DB = STATE_DIR / "played.db"
|
||||||
|
PODCASTS_DIR = storage / "podcasts"
|
||||||
|
PLAYLISTS_DIR = storage / "playlists"
|
||||||
|
LOGS_DIR = storage / "logs"
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
handlers=[
|
handlers=[
|
||||||
logging.FileHandler(LOGS_DIR / "update.log"),
|
|
||||||
logging.StreamHandler(sys.stdout),
|
logging.StreamHandler(sys.stdout),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
log = logging.getLogger("update_playlists")
|
log = logging.getLogger("update_playlists")
|
||||||
|
|
||||||
|
def _setup_logging():
|
||||||
|
fh = logging.FileHandler(LOGS_DIR / "update.log")
|
||||||
|
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
||||||
|
log.addHandler(fh)
|
||||||
|
|
||||||
def open_subs_db():
|
def open_subs_db():
|
||||||
conn = sqlite3.connect(SUBS_DB)
|
conn = sqlite3.connect(SUBS_DB)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
@ -56,7 +76,6 @@ def open_played_db():
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
def find_audio_files(directory):
|
def find_audio_files(directory):
|
||||||
"""Recursively scan for .mp3/.m4a files."""
|
|
||||||
results = []
|
results = []
|
||||||
if not directory.exists():
|
if not directory.exists():
|
||||||
return results
|
return results
|
||||||
|
|
@ -66,34 +85,28 @@ def find_audio_files(directory):
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def select_unplayed_episode(slug, played_db):
|
def select_unplayed_episode(slug, played_db):
|
||||||
"""Pick one unplayed episode for the show, falling back to least-recently-played."""
|
|
||||||
files = find_audio_files(PODCASTS_DIR / slug)
|
files = find_audio_files(PODCASTS_DIR / slug)
|
||||||
if not files:
|
if not files:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
played_rows = played_db.execute(
|
played_rows = played_db.execute(
|
||||||
"SELECT file_path, played_at FROM episodes WHERE show_slug = ? AND played_at IS NOT NULL",
|
"SELECT file_path, played_at FROM episodes WHERE show_slug = ? AND played_at IS NOT NULL",
|
||||||
(slug,),
|
(slug,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
played_paths = {row["file_path"]: row["played_at"] for row in played_rows}
|
played_paths = {row["file_path"]: row["played_at"] for row in played_rows}
|
||||||
|
|
||||||
unplayed = [f for f in files if f not in played_paths]
|
unplayed = [f for f in files if f not in played_paths]
|
||||||
if unplayed:
|
if unplayed:
|
||||||
return unplayed[0]
|
return unplayed[0]
|
||||||
|
|
||||||
# All played: pick least recently played
|
|
||||||
if played_paths:
|
if played_paths:
|
||||||
return min(played_paths.items(), key=lambda kv: (kv[1] or ""))[0]
|
return min(played_paths.items(), key=lambda kv: (kv[1] or ""))[0]
|
||||||
return files[0]
|
return files[0]
|
||||||
|
|
||||||
def write_pls(filepath, out_path):
|
def write_pls(filepath, out_path):
|
||||||
"""Write a .pls playlist pointing at a single file."""
|
|
||||||
abs_path = str(Path(filepath).resolve())
|
abs_path = str(Path(filepath).resolve())
|
||||||
content = f"[playlist]\nFile1={abs_path}\nTitle1=Radio Episode\nLength1=-1\nNumberOfEntries=1\nVersion=2\n"
|
content = f"[playlist]\nFile1={abs_path}\nTitle1=Radio Episode\nLength1=-1\nNumberOfEntries=1\nVersion=2\n"
|
||||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
out_path.write_text(content)
|
out_path.write_text(content)
|
||||||
|
|
||||||
def mark_as_played(slug, filepath, duration, played_db):
|
def mark_as_played(slug, filepath, played_db):
|
||||||
played_db.execute(
|
played_db.execute(
|
||||||
"UPDATE episodes SET played_at = datetime('now') WHERE show_slug = ? AND file_path = ?",
|
"UPDATE episodes SET played_at = datetime('now') WHERE show_slug = ? AND file_path = ?",
|
||||||
(slug, filepath),
|
(slug, filepath),
|
||||||
|
|
@ -104,25 +117,16 @@ def update_all():
|
||||||
subs_db = open_subs_db()
|
subs_db = open_subs_db()
|
||||||
played_db = open_played_db()
|
played_db = open_played_db()
|
||||||
shows = subs_db.execute("SELECT slug, name FROM shows ORDER BY name").fetchall()
|
shows = subs_db.execute("SELECT slug, name FROM shows ORDER BY name").fetchall()
|
||||||
|
|
||||||
for show in shows:
|
for show in shows:
|
||||||
slug = show["slug"]
|
slug = show["slug"]
|
||||||
selected = select_unplayed_episode(slug, played_db)
|
selected = select_unplayed_episode(slug, played_db)
|
||||||
if selected is None:
|
if selected is None:
|
||||||
log.info("%s: no audio files found, skipping.", slug)
|
log.info("%s: no audio files found, skipping.", slug)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
duration_row = played_db.execute(
|
|
||||||
"SELECT duration_seconds FROM episodes WHERE show_slug = ? AND file_path = ?",
|
|
||||||
(slug, selected),
|
|
||||||
).fetchone()
|
|
||||||
duration = duration_row["duration_seconds"] if duration_row else None
|
|
||||||
|
|
||||||
out_pls = PLAYLISTS_DIR / f"{slug}.pls"
|
out_pls = PLAYLISTS_DIR / f"{slug}.pls"
|
||||||
write_pls(selected, out_pls)
|
write_pls(selected, out_pls)
|
||||||
mark_as_played(slug, selected, duration, played_db)
|
mark_as_played(slug, selected, played_db)
|
||||||
log.info("%s: queued %s", slug, Path(selected).name)
|
log.info("%s: queued %s", slug, Path(selected).name)
|
||||||
|
|
||||||
subs_db.close()
|
subs_db.close()
|
||||||
played_db.close()
|
played_db.close()
|
||||||
|
|
||||||
|
|
@ -148,9 +152,11 @@ def main():
|
||||||
parser.add_argument("--json", action="store_true", help="Emit JSON summary and exit")
|
parser.add_argument("--json", action="store_true", help="Emit JSON summary and exit")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
STATE_DIR.mkdir(exist_ok=True)
|
init_paths()
|
||||||
LOGS_DIR.mkdir(exist_ok=True)
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
PLAYLISTS_DIR.mkdir(exist_ok=True)
|
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
PLAYLISTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
_setup_logging()
|
||||||
|
|
||||||
if args.json:
|
if args.json:
|
||||||
json_summary()
|
json_summary()
|
||||||
|
|
@ -159,4 +165,3 @@ def main():
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,29 @@ require "optparse"
|
||||||
|
|
||||||
module RadioAutomation
|
module RadioAutomation
|
||||||
ROOT = File.expand_path("..", __dir__)
|
ROOT = File.expand_path("..", __dir__)
|
||||||
STATE_DIR = File.join(ROOT, "state")
|
CONFIG_PATH = File.join(ROOT, "config.json")
|
||||||
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"]
|
AUDIO_EXTS = [".mp3", ".m4a"]
|
||||||
|
|
||||||
|
STORAGE_DIR = nil
|
||||||
|
STATE_DIR = nil
|
||||||
|
SUBS_DB = nil
|
||||||
|
PLAYED_DB = nil
|
||||||
|
PODCASTS_DIR = nil
|
||||||
|
PLAYLISTS_DIR = nil
|
||||||
|
LOGS_DIR = nil
|
||||||
|
|
||||||
|
def self.init_paths
|
||||||
|
cfg = JSON.parse(File.read(CONFIG_PATH))
|
||||||
|
@storage = File.expand_path(cfg["storage"])
|
||||||
|
self.STORAGE_DIR = @storage
|
||||||
|
self.STATE_DIR = File.join(@storage, "state")
|
||||||
|
self.SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
|
||||||
|
self.PLAYED_DB = File.join(STATE_DIR, "played.db")
|
||||||
|
self.PODCASTS_DIR = File.join(@storage, "podcasts")
|
||||||
|
self.PLAYLISTS_DIR = File.join(@storage, "playlists")
|
||||||
|
self.LOGS_DIR = File.join(@storage, "logs")
|
||||||
|
end
|
||||||
|
|
||||||
def self.log_info(msg)
|
def self.log_info(msg)
|
||||||
puts "#{Time.now.iso8601} [INFO] #{msg}"
|
puts "#{Time.now.iso8601} [INFO] #{msg}"
|
||||||
append_log("update.log", msg)
|
append_log("update.log", msg)
|
||||||
|
|
@ -139,6 +154,7 @@ module RadioAutomation
|
||||||
opts.on("--json", "Emit JSON summary and exit") { options[:json] = true }
|
opts.on("--json", "Emit JSON summary and exit") { options[:json] = true }
|
||||||
end.parse!
|
end.parse!
|
||||||
|
|
||||||
|
init_paths
|
||||||
FileUtils.mkdir_p(STATE_DIR)
|
FileUtils.mkdir_p(STATE_DIR)
|
||||||
FileUtils.mkdir_p(LOGS_DIR)
|
FileUtils.mkdir_p(LOGS_DIR)
|
||||||
FileUtils.mkdir_p(PLAYLISTS_DIR)
|
FileUtils.mkdir_p(PLAYLISTS_DIR)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue