Initial Commit

This commit is contained in:
G. Gibson 2026-08-24 13:23:04 -07:00
commit 5a87c50f7d
10 changed files with 2710 additions and 0 deletions

439
LICENSE.txt Normal file
View file

@ -0,0 +1,439 @@
HIPPOCRATIC LICENSE
Version 3.0, October 2021
https://firstdonoharm.dev/version/3/0/full.txt
TERMS AND CONDITIONS
TERMS AND CONDITIONS FOR USE, COPY, MODIFICATION, PREPARATION OF DERIVATIVE
WORK, REPRODUCTION, AND DISTRIBUTION:
1. DEFINITIONS:
This section defines certain terms used throughout this license agreement.
1.1. “License” means the terms and conditions, as stated herein, for use, copy,
modification, preparation of derivative work, reproduction, and distribution of
Software (as defined below).
1.2. “Licensor” means the copyright and/or patent owner or entity authorized by
the copyright and/or patent owner that is granting the License.
1.3. “Licensee” means the individual or entity exercising permissions granted by
this License, including the use, copy, modification, preparation of derivative
work, reproduction, and distribution of Software (as defined below).
1.4. “Software” means any copyrighted work, including but not limited to
software code, authored by Licensor and made available under this License.
1.5. “Supply Chain” means the sequence of processes involved in the production
and/or distribution of a commodity, good, or service offered by the Licensee.
1.6. “Supply Chain Impacted Party” or “Supply Chain Impacted Parties” means any
person(s) directly impacted by any of Licensees Supply Chain, including the
practices of all persons or entities within the Supply Chain prior to a good or
service reaching the Licensee.
1.7. “Duty of Care” is defined by its use in tort law, delict law, and/or
similar bodies of law closely related to tort and/or delict law, including
without limitation, a requirement to act with the watchfulness, attention,
caution, and prudence that a reasonable person in the same or similar
circumstances would use towards any Supply Chain Impacted Party.
1.8. “Worker” is defined to include any and all permanent, temporary, and agency
workers, as well as piece-rate, salaried, hourly paid, legal young (minors),
part-time, night, and migrant workers.
2. INTELLECTUAL PROPERTY GRANTS:
This section identifies intellectual property rights granted to a Licensee.
2.1. Grant of Copyright License: Subject to the terms and conditions of this
License, Licensor hereby grants to Licensee a worldwide, non-exclusive,
no-charge, royalty-free copyright license to use, copy, modify, prepare
derivative work, reproduce, or distribute the Software, Licensor authored
modified software, or other work derived from the Software.
2.2. Grant of Patent License: Subject to the terms and conditions of this
License, Licensor hereby grants Licensee a worldwide, non-exclusive, no-charge,
royalty-free patent license to make, have made, use, offer to sell, sell,
import, and otherwise transfer Software.
3. ETHICAL STANDARDS:
This section lists conditions the Licensee must comply with in order to have
rights under this License.
The rights granted to the Licensee by this License are expressly made subject to
the Licensees ongoing compliance with the following conditions:
* 3.1. The Licensee SHALL NOT, whether directly or indirectly, through agents
or assigns:
* 3.1.1. Infringe upon any persons right to life or security of person,
engage in extrajudicial killings, or commit murder, without lawful cause
(See Article 3, United Nations Universal Declaration of Human Rights;
Article 6, International Covenant on Civil and Political Rights)
* 3.1.2. Hold any person in slavery, servitude, or forced labor (See Article
4, United Nations Universal Declaration of Human Rights; Article 8,
International Covenant on Civil and Political Rights);
* 3.1.3. Contribute to the institution of slavery, slave trading, forced
labor, or unlawful child labor (See Article 4, United Nations Universal
Declaration of Human Rights; Article 8, International Covenant on Civil and
Political Rights);
* 3.1.4. Torture or subject any person to cruel, inhumane, or degrading
treatment or punishment (See Article 5, United Nations Universal
Declaration of Human Rights; Article 7, International Covenant on Civil and
Political Rights);
* 3.1.5. Discriminate on the basis of sex, gender, sexual orientation, race,
ethnicity, nationality, religion, caste, age, medical disability or
impairment, and/or any other like circumstances (See Article 7, United
Nations Universal Declaration of Human Rights; Article 2, International
Covenant on Economic, Social and Cultural Rights; Article 26, International
Covenant on Civil and Political Rights);
* 3.1.6. Prevent any person from exercising his/her/their right to seek an
effective remedy by a competent court or national tribunal (including
domestic judicial systems, international courts, arbitration bodies, and
other adjudicating bodies) for actions violating the fundamental rights
granted to him/her/them by applicable constitutions, applicable laws, or by
this License (See Article 8, United Nations Universal Declaration of Human
Rights; Articles 9 and 14, International Covenant on Civil and Political
Rights);
* 3.1.7. Subject any person to arbitrary arrest, detention, or exile (See
Article 9, United Nations Universal Declaration of Human Rights; Article 9,
International Covenant on Civil and Political Rights);
* 3.1.8. Subject any person to arbitrary interference with a persons
privacy, family, home, or correspondence without the express written
consent of the person (See Article 12, United Nations Universal Declaration
of Human Rights; Article 17, International Covenant on Civil and Political
Rights);
* 3.1.9. Arbitrarily deprive any person of his/her/their property (See
Article 17, United Nations Universal Declaration of Human Rights);
* 3.1.10. Forcibly remove indigenous peoples from their lands or territories
or take any action with the aim or effect of dispossessing indigenous
peoples from their lands, territories, or resources, including without
limitation the intellectual property or traditional knowledge of indigenous
peoples, without the free, prior, and informed consent of indigenous
peoples concerned (See Articles 8 and 10, United Nations Declaration on the
Rights of Indigenous Peoples);
* 3.1.11. Fossil Fuel Divestment: Be an individual or entity, or a
representative, agent, affiliate, successor, attorney, or assign of an
individual or entity, on the FFI Solutions Carbon Underground 200 list
[https://www.ffisolutions.com/research-analytics-index-solutions/research-screening/the-carbon-underground-200/?cn-reloaded=1];
* 3.1.12. Ecocide: Commit ecocide:
* 3.1.12.1. For the purpose of this section, “ecocide” means unlawful or
wanton acts committed with knowledge that there is a substantial
likelihood of severe and either widespread or long-term damage to the
environment being caused by those acts;
* 3.1.12.2. For the purpose of further defining ecocide and the terms
contained in the previous paragraph:
* 3.1.12.2.1. “Wanton” means with reckless disregard for damage which
would be clearly excessive in relation to the social and economic
benefits anticipated;
* 3.1.12.2.2. “Severe” means damage which involves very serious adverse
changes, disruption, or harm to any element of the environment,
including grave impacts on human life or natural, cultural, or
economic resources;
* 3.1.12.2.3. “Widespread” means damage which extends beyond a limited
geographic area, crosses state boundaries, or is suffered by an entire
ecosystem or species or a large number of human beings;
* 3.1.12.2.4. “Long-term” means damage which is irreversible or which
cannot be redressed through natural recovery within a reasonable
period of time; and
* 3.1.12.2.5. “Environment” means the earth, its biosphere, cryosphere,
lithosphere, hydrosphere, and atmosphere, as well as outer space
(See Section II, Independent Expert Panel for the Legal Definition of
Ecocide, Stop Ecocide Foundation and the Promise Institute for Human
Rights at UCLA School of Law, June 2021);
* 3.1.13. Extractive Industries: Be an individual or entity, or a
representative, agent, affiliate, successor, attorney, or assign of an
individual or entity, that engages in fossil fuel or mineral exploration,
extraction, development, or sale;
* 3.1.14. Boycott / Divestment / Sanctions: Be an individual or entity, or a
representative, agent, affiliate, successor, attorney, or assign of an
individual or entity, identified by the Boycott, Divestment, Sanctions
(“BDS”) movement on its website (https://bdsmovement.net/
[https://bdsmovement.net/] and
https://bdsmovement.net/get-involved/what-to-boycott
[https://bdsmovement.net/get-involved/what-to-boycott]) as a target for
boycott;
* 3.1.15. Taliban: Be an individual or entity that:
* 3.1.15.1. engages in any commercial transactions with the Taliban; or
* 3.1.15.2. is a representative, agent, affiliate, successor, attorney, or
assign of the Taliban;
* 3.1.16. Myanmar: Be an individual or entity that:
* 3.1.16.1. engages in any commercial transactions with the
Myanmar/Burmese military junta; or
* 3.1.16.2. is a representative, agent, affiliate, successor, attorney, or
assign of the Myanmar/Burmese government;
* 3.1.17. Xinjiang Uygur Autonomous Region: Be an individual or entity, or a
representative, agent, affiliate, successor, attorney, or assign of any
individual or entity, that does business in, purchases goods from, or
otherwise benefits from goods produced in the Xinjiang Uygur Autonomous
Region of China;
* 3.1.18. US Tariff Act: Be an individual or entity:
* 3.1.18.1. which U.S. Customs and Border Protection (CBP) has currently
issued a Withhold Release Order (WRO) or finding against based on
reasonable suspicion of forced labor; or
* 3.1.18.2. that is a representative, agent, affiliate, successor,
attorney, or assign of an individual or entity that does business with
an individual or entity which currently has a WRO or finding from CBP
issued against it based on reasonable suspicion of forced labor;
* 3.1.19. Mass Surveillance: Be a government agency or multinational
corporation, or a representative, agent, affiliate, successor, attorney,
or assign of a government or multinational corporation, which participates
in mass surveillance programs;
* 3.1.20. Military Activities: Be an entity or a representative, agent,
affiliate, successor, attorney, or assign of an entity which conducts
military activities;
* 3.1.21. Law Enforcement: Be an individual or entity, or a representative,
agent, affiliate, successor, attorney, or assign of an individual or
entity, that provides good or services to, or otherwise enters into any
commercial contracts with, any local, state, or federal law enforcement
agency;
* 3.1.22. Media: Be an individual or entity, or a representative, agent,
affiliate, successor, attorney, or assign of an individual or entity, that
broadcasts messages promoting killing, torture, or other forms of extreme
violence;
* 3.1.23. Interfere with Workers free exercise of the right to organize and
associate (See Article 20, United Nations Universal Declaration of Human
Rights; C087 - Freedom of Association and Protection of the Right to
Organise Convention, 1948 (No. 87), International Labour Organization;
Article 8, International Covenant on Economic, Social and Cultural Rights);
and
* 3.1.24. Harm the environment in a manner inconsistent with local, state,
national, or international law.
* 3.2. The Licensee SHALL:
* 3.2.1. Social Auditing: Only use social auditing mechanisms that adhere to
Worker-Driven Social Responsibility Networks Statement of Principles
(https://wsr-network.org/what-is-wsr/statement-of-principles/
[https://wsr-network.org/what-is-wsr/statement-of-principles/]) over
traditional social auditing mechanisms, to the extent the Licensee uses
any social auditing mechanisms at all;
* 3.2.2. Workers on Board of Directors: Ensure that if the Licensee has a
Board of Directors, 30% of Licensees board seats are held by Workers paid
no more than 200% of the compensation of the lowest paid Worker of the
Licensee;
* 3.2.3. Supply Chain: Provide clear, accessible supply chain data to the
public in accordance with the following conditions:
* 3.2.3.1. All data will be on Licensees website and/or, to the extent
Licensee is a representative, agent, affiliate, successor, attorney,
subsidiary, or assign, on Licensees principals or parents website or
some other online platform accessible to the public via an internet
search on a common internet search engine; and
* 3.2.3.2. Data published will include, where applicable, manufacturers,
top tier suppliers, subcontractors, cooperatives, component parts
producers, and farms;
* 3.2.4. Provide equal pay for equal work where the performance of such work
requires equal skill, effort, and responsibility, and which are performed
under similar working conditions, except where such payment is made
pursuant to:
* 3.2.4.1. A seniority system;
* 3.2.4.2. A merit system;
* 3.2.4.3. A system which measures earnings by quantity or quality of
production; or
* 3.2.4.4. A differential based on any other factor other than sex, gender,
sexual orientation, race, ethnicity, nationality, religion, caste, age,
medical disability or impairment, and/or any other like circumstances
(See 29 U.S.C.A. § 206(d)(1); Article 23, United Nations Universal
Declaration of Human Rights; Article 7, International Covenant on
Economic, Social and Cultural Rights; Article 26, International Covenant
on Civil and Political Rights); and
* 3.2.5. Allow for reasonable limitation of working hours and periodic
holidays with pay (See Article 24, United Nations Universal Declaration of
Human Rights; Article 7, International Covenant on Economic, Social and
Cultural Rights).
4. SUPPLY CHAIN IMPACTED PARTIES:
This section identifies additional individuals or entities that a Licensee could
harm as a result of violating the Ethical Standards section, the condition that
the Licensee must voluntarily accept a Duty of Care for those individuals or
entities, and the right to a private right of action that those individuals or
entities possess as a result of violations of the Ethical Standards section.
4.1. In addition to the above Ethical Standards, Licensee voluntarily accepts a
Duty of Care for Supply Chain Impacted Parties of this License, including
individuals and communities impacted by violations of the Ethical Standards. The
Duty of Care is breached when a provision within the Ethical Standards section
is violated by a Licensee, one of its successors or assigns, or by an individual
or entity that exists within the Supply Chain prior to a good or service
reaching the Licensee.
4.2. Breaches of the Duty of Care, as stated within this section, shall create a
private right of action, allowing any Supply Chain Impacted Party harmed by the
Licensee to take legal action against the Licensee in accordance with applicable
negligence laws, whether they be in tort law, delict law, and/or similar bodies
of law closely related to tort and/or delict law, regardless if Licensee is
directly responsible for the harms suffered by a Supply Chain Impacted Party.
Nothing in this section shall be interpreted to include acts committed by
individuals outside of the scope of his/her/their employment.
5. NOTICE: This section explains when a Licensee must notify others of the
License.
5.1. Distribution of Notice: Licensee must ensure that everyone who receives a
copy of or uses any part of Software from Licensee, with or without changes,
also receives the License and the copyright notice included with Software (and
if included by the Licensor, patent, trademark, and attribution notice).
Licensee must ensure that License is prominently displayed so that any
individual or entity seeking to download, copy, use, or otherwise receive any
part of Software from Licensee is notified of this License and its terms and
conditions. Licensee must cause any modified versions of the Software to carry
prominent notices stating that Licensee changed the Software.
5.2. Modified Software: Licensee is free to create modifications of the Software
and distribute only the modified portion created by Licensee, however, any
derivative work stemming from the Software or its code must be distributed
pursuant to this License, including this Notice provision.
5.3. Recipients as Licensees: Any individual or entity that uses, copies,
modifies, reproduces, distributes, or prepares derivative work based upon the
Software, all or part of the Softwares code, or a derivative work developed by
using the Software, including a portion of its code, is a Licensee as defined
above and is subject to the terms and conditions of this License.
6. REPRESENTATIONS AND WARRANTIES:
6.1. Disclaimer of Warranty: TO THE FULL EXTENT ALLOWED BY LAW, THIS SOFTWARE
COMES “AS IS,” WITHOUT ANY WARRANTY, EXPRESS OR IMPLIED, AND LICENSOR SHALL NOT
BE LIABLE TO ANY PERSON OR ENTITY FOR ANY DAMAGES OR OTHER LIABILITY ARISING
FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THIS LICENSE, UNDER ANY
LEGAL CLAIM.
6.2. Limitation of Liability: LICENSEE SHALL HOLD LICENSOR HARMLESS AGAINST ANY
AND ALL CLAIMS, DEBTS, DUES, LIABILITIES, LIENS, CAUSES OF ACTION, DEMANDS,
OBLIGATIONS, DISPUTES, DAMAGES, LOSSES, EXPENSES, ATTORNEYS FEES, COSTS,
LIABILITIES, AND ALL OTHER CLAIMS OF EVERY KIND AND NATURE WHATSOEVER, WHETHER
KNOWN OR UNKNOWN, ANTICIPATED OR UNANTICIPATED, FORESEEN OR UNFORESEEN, ACCRUED
OR UNACCRUED, DISCLOSED OR UNDISCLOSED, ARISING OUT OF OR RELATING TO LICENSEES
USE OF THE SOFTWARE. NOTHING IN THIS SECTION SHOULD BE INTERPRETED TO REQUIRE
LICENSEE TO INDEMNIFY LICENSOR, NOR REQUIRE LICENSOR TO INDEMNIFY LICENSEE.
7. TERMINATION
7.1. Violations of Ethical Standards or Breaching Duty of Care: If Licensee
violates the Ethical Standards section or Licensee, or any other person or
entity within the Supply Chain prior to a good or service reaching the Licensee,
breaches its Duty of Care to Supply Chain Impacted Parties, Licensee must remedy
the violation or harm caused by Licensee within 30 days of being notified of the
violation or harm. If Licensee fails to remedy the violation or harm within 30
days, all rights in the Software granted to Licensee by License will be null and
void as between Licensor and Licensee.
7.2. Failure of Notice: If any person or entity notifies Licensee in writing
that Licensee has not complied with the Notice section of this License, Licensee
can keep this License by taking all practical steps to comply within 30 days
after the notice of noncompliance. If Licensee does not do so, Licensees
License (and all rights licensed hereunder) will end immediately.
7.3. Judicial Findings: In the event Licensee is found by a civil, criminal,
administrative, or other court of competent jurisdiction, or some other
adjudicating body with legal authority, to have committed actions which are in
violation of the Ethical Standards or Supply Chain Impacted Party sections of
this License, all rights granted to Licensee by this License will terminate
immediately.
7.4. Patent Litigation: If Licensee institutes patent litigation against any
entity (including a cross-claim or counterclaim in a suit) alleging that the
Software, all or part of the Softwares code, or a derivative work developed
using the Software, including a portion of its code, constitutes direct or
contributory patent infringement, then any patent license, along with all other
rights, granted to Licensee under this License will terminate as of the date
such litigation is filed.
7.5. Additional Remedies: Termination of the License by failing to remedy harms
in no way prevents Licensor or Supply Chain Impacted Party from seeking
appropriate remedies at law or in equity.
8. MISCELLANEOUS:
8.1. Conditions: Sections 3, 4.1, 5.1, 5.2, 7.1, 7.2, 7.3, and 7.4 are
conditions of the rights granted to Licensee in the License.
8.2. Equitable Relief: Licensor and any Supply Chain Impacted Party shall be
entitled to equitable relief, including injunctive relief or specific
performance of the terms hereof, in addition to any other remedy to which they
are entitled at law or in equity.
8.3. Copyleft: Modified software, source code, or other derivative work must be
licensed, in its entirety, under the exact same conditions as this License.
8.4. Severability: If any term or provision of this License is determined to be
invalid, illegal, or unenforceable by a court of competent jurisdiction, any
such determination of invalidity, illegality, or unenforceability shall not
affect any other term or provision of this License or invalidate or render
unenforceable such term or provision in any other jurisdiction. If the
determination of invalidity, illegality, or unenforceability by a court of
competent jurisdiction pertains to the terms or provisions contained in the
Ethical Standards section of this License, all rights in the Software granted to
Licensee shall be deemed null and void as between Licensor and Licensee.
8.5. Section Titles: Section titles are solely written for organizational
purposes and should not be used to interpret the language within each section.
8.6. Citations: Citations are solely written to provide context for the source
of the provisions in the Ethical Standards.
8.7. Section Summaries: Some sections have a brief italicized description which
is provided for the sole purpose of briefly describing the section and should
not be used to interpret the terms of the License.
8.8. Entire License: This is the entire License between the Licensor and
Licensee with respect to the claims released herein and that the consideration
stated herein is the only consideration or compensation to be paid or exchanged
between them for this License. This License cannot be modified or amended except
in a writing signed by Licensor and Licensee.
8.9. Successors and Assigns: This License shall be binding upon and inure to the
benefit of the Licensors and Licensees respective heirs, successors, and
assigns.

459
fetch_podcasts.py Executable file
View file

@ -0,0 +1,459 @@
#!/usr/bin/env python3
"""Fetch podcast episodes from RSS feeds, manage subscriptions, and download audio."""
import os
import sys
import json
import shutil
import sqlite3
import hashlib
import xml.etree.ElementTree as ET
from pathlib import Path
from datetime import datetime
from urllib.parse import urlparse
import feedparser
import requests
AUDIO_ROOT = Path(__file__).resolve().parent
DB_PATH = AUDIO_ROOT / "state" / "subscriptions.db"
DOWNLOAD_DIR = AUDIO_ROOT / "podcasts"
CONFIG_PATH = AUDIO_ROOT / "config.json"
def load_config():
with open(CONFIG_PATH, "r") as f:
return json.load(f)
def get_db():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.execute("""CREATE TABLE IF NOT EXISTS shows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
feed_url TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL,
opml_import INTEGER DEFAULT 0
)""")
conn.execute("""CREATE TABLE IF NOT EXISTS seen (
url TEXT PRIMARY KEY,
title TEXT,
show_slug TEXT,
duration_sec INTEGER DEFAULT 0,
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""")
# Migrate existing databases that lack the new column
cols = [row[1] for row in conn.execute("PRAGMA table_info(shows)").fetchall()]
if "opml_import" not in cols:
conn.execute("ALTER TABLE shows ADD COLUMN opml_import INTEGER DEFAULT 0")
conn.commit()
return conn
def sanitize_slug(name):
"""Convert a show name to a filesystem-safe slug."""
slug = name.lower().strip()
slug = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in slug)
slug = "_".join(slug.split("_"))
return slug[:80]
def register_shows_from_opml_xml(xml_bytes, db, opml_import=False):
"""Register shows from OPML XML data.
Uses upsert semantics: if a show with the same feed_url already exists,
no duplicate row is created. Its opml_import flag is upgraded to 1 if
this import marks it as such (protecting it from gpodder pruning).
"""
root = ET.fromstring(xml_bytes)
added = 0
updated_flag = 0
skipped = 0
for outline in root.iter("outline"):
xml_url = outline.get("xmlUrl", "")
if not xml_url:
continue
otype = outline.get("type", "")
if otype and otype != "rss":
continue
show_name = outline.get("text") or outline.get("title") or "Unknown Show"
slug = sanitize_slug(show_name)
flag = 1 if opml_import else 0
existing = db.execute(
"SELECT id, opml_import FROM shows WHERE feed_url=?", (xml_url,)
).fetchone()
if existing:
existing_id, existing_flag = existing
if flag == 1 and existing_flag == 0:
db.execute(
"UPDATE shows SET opml_import=1 WHERE id=?", (existing_id,)
)
updated_flag += 1
print(f" Protected existing: {show_name} ({slug})")
else:
skipped += 1
else:
db.execute(
"INSERT INTO shows (name, feed_url, slug, opml_import) VALUES (?, ?, ?, ?)",
(show_name, xml_url, slug, flag)
)
added += 1
print(f" Registered: {show_name} -> {slug}")
db.commit()
if added:
print(f" Added {added} new show(s).")
if updated_flag:
print(f" Upgraded {updated_flag} show(s) to protected.")
if skipped:
print(f" Skipped {skipped} duplicate(s).")
return added
def import_opml_file(filepath):
"""Import shows from a local OPML file."""
try:
with open(filepath, "rb") as f:
xml_data = f.read()
except FileNotFoundError:
print(f"ERROR: File not found: {filepath}")
sys.exit(1)
db = get_db()
count = register_shows_from_opml_xml(xml_data, db, opml_import=True)
total = db.execute("SELECT COUNT(*) FROM shows").fetchone()[0]
db.close()
print(f"\nImport complete. {count} new show(s) added. Total registered: {total}")
def sync_gpoddernet():
"""Sync subscriptions from gpodder.net, register new shows, prune removed ones."""
cfg = load_config()
gp = cfg["gpodder"]
if not gp.get("enable", False):
print("gpodder.net sync is disabled in config.json.")
return
if not gp.get("username") or not gp.get("password"):
print("ERROR: gpodder.username/gpodder.password not set in config.json")
sys.exit(1)
url = f"{gp['host']}/subscriptions/{gp['username']}.opml"
print(f"Fetching subscriptions from {gp['host']} for '{gp['username']}'...")
resp = requests.get(url, auth=(gp["username"], gp["password"]), timeout=30)
if resp.status_code == 401:
print("ERROR: Authentication failed. Check username/password in config.json.")
sys.exit(1)
elif resp.status_code != 200:
print(f"ERROR: Unexpected response {resp.status_code}: {resp.text[:200]}")
sys.exit(1)
db = get_db()
# Collect all feed URLs from the current subscription list
root = ET.fromstring(resp.content)
gp_feed_urls = set()
for outline in root.iter("outline"):
fu = outline.get("xmlUrl", "")
if fu:
gp_feed_urls.add(fu)
# Register any new shows
count = register_shows_from_opml_xml(resp.content, db, opml_import=False)
total = db.execute("SELECT COUNT(*) FROM shows").fetchone()[0]
# Prune shows that were unsubscribed
prune_removed_shows(gp_feed_urls, db)
db.close()
print(f"\nSync complete. {count} new, total registered: {total}")
def prune_removed_shows(gp_feed_urls, db):
"""Remove shows not in current gpodder list (unless opml_import=1)."""
rows = db.execute(
"SELECT slug, feed_url, name FROM shows WHERE opml_import = 0"
).fetchall()
to_remove = []
for slug, feed_url, name in rows:
if feed_url not in gp_feed_urls:
to_remove.append((slug, name))
if not to_remove:
return
for slug, name in to_remove:
print(f" Removing unsubscribed show: {name} ({slug})")
db.execute("DELETE FROM seen WHERE show_slug=?", (slug,))
db.execute("DELETE FROM shows WHERE slug=?", (slug))
show_dir = DOWNLOAD_DIR / slug
if show_dir.exists():
shutil.rmtree(show_dir)
print(f" Deleted directory: {show_dir}")
db.commit()
print(f" Pruned {len(to_remove)} removed show(s).")
def delete_show(slug):
"""Manually delete a show and all associated data."""
db = get_db()
row = db.execute("SELECT name FROM shows WHERE slug=?", (slug,)).fetchone()
if not row:
print(f"No show found with slug '{slug}'.")
db.close()
return
name = row[0]
print(f"Deleting show: {name} ({slug})")
db.execute("DELETE FROM seen WHERE show_slug=?", (slug,))
db.execute("DELETE FROM shows WHERE slug=?", (slug))
db.commit()
db.close()
show_dir = DOWNLOAD_DIR / slug
if show_dir.exists():
shutil.rmtree(show_dir)
print(f"Deleted directory: {show_dir}")
pls_file = AUDIO_ROOT / "playlists" / f"{slug}.pls"
if pls_file.exists():
pls_file.unlink()
print(f"Deleted playlist: {pls_file}")
print("Done.")
def add_show(feed_url):
"""Register a new show from a feed URL and fetch its initial episodes."""
print(f"Fetching feed: {feed_url}")
try:
d = feedparser.parse(feed_url)
except Exception as e:
print(f"ERROR: Failed to parse feed: {e}")
sys.exit(1)
if d.bozo and not d.entries:
print(f"ERROR: Invalid or unreachable feed. Bozo exception: {d.get('bozo_exception', 'unknown')}")
sys.exit(1)
show_name = d.feed.get("title", "Unknown Show")
slug = sanitize_slug(show_name)
entry_count = len(d.entries)
print(f" Title: {show_name}")
print(f" Slug: {slug}")
print(f" Entries found: {entry_count}")
db = get_db()
existing = db.execute("SELECT name FROM shows WHERE feed_url=?", (feed_url,)).fetchone()
if existing:
print(f"NOTE: Feed already registered as '{existing[0]}'. Nothing to do.")
db.close()
return
db.execute(
"INSERT INTO shows (name, feed_url, slug, opml_import) VALUES (?, ?, ?, 1)",
(show_name, feed_url, slug)
)
db.commit()
print(f" Registered: {show_name} -> {slug}")
print(f"\n--- Fetching episodes ---")
fetch_feed(show_name, feed_url, slug, db)
db.close()
print(f"\nDone. Episodes saved to: {DOWNLOAD_DIR / slug}/")
def fetch_feed(show_name, feed_url, slug, db):
"""Parse a feed and download any new episodes."""
d = feedparser.parse(feed_url)
if d.bozo and not d.entries:
print(f"[{show_name}] ERROR: Could not parse feed.")
return
show_dir = DOWNLOAD_DIR / slug
show_dir.mkdir(parents=True, exist_ok=True)
new_count = 0
for entry in d.entries:
link = entry.get("link", "")
title = entry.get("title", "untitled")
# Find the enclosure (audio file)
enclosure = None
if hasattr(entry, "enclosures") and entry.enclosures:
enc = entry.enclosures[0]
enclosure = {"url": enc.href, "type": enc.type, "length": getattr(enc, "length", "0")}
elif "media_content" in entry:
mc = entry.media_content[0]
enclosure = {"url": mc.url, "type": mc.type, "length": getattr(mc, "duration", "0")}
if not enclosure:
continue
ep_url = enclosure["url"]
# Check if already downloaded
row = db.execute("SELECT 1 FROM seen WHERE url=?", (ep_url,)).fetchone()
if row:
continue
# Extract duration from enclosure length (seconds) or media:duration
duration_sec = 0
try:
raw_len = str(enclosure.get("length", "0"))
if ":" in raw_len:
parts = raw_len.split(":")
duration_sec = int(parts[-1])
else:
duration_sec = int(raw_len)
except (ValueError, IndexError):
pass
# Sanitize filename
safe_title = "".join(c if c.isalnum() or c in ("-", "_", " ") else "_" for c in title)
safe_title = safe_title.strip()[:120]
ext = ".mp3"
if "ogg" in enclosure.get("type", "").lower():
ext = ".ogg"
elif "m4a" in enclosure.get("type", "").lower() or "aac" in enclosure.get("type", "").lower():
ext = ".m4a"
filepath = show_dir / f"{safe_title}{ext}"
if filepath.exists():
db.execute(
"INSERT OR IGNORE INTO seen (url, title, show_slug, duration_sec) VALUES (?, ?, ?, ?)",
(ep_url, title, slug, duration_sec)
)
db.commit()
continue
# Download
try:
print(f"[{show_name}] Downloading: {title}")
resp = requests.get(ep_url, stream=True, timeout=120)
resp.raise_for_status()
with open(filepath, "wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
f.write(chunk)
db.execute(
"INSERT OR IGNORE INTO seen (url, title, show_slug, duration_sec) VALUES (?, ?, ?, ?)",
(ep_url, title, slug, duration_sec)
)
db.commit()
new_count += 1
except Exception as e:
print(f"[{show_name}] FAILED to download '{title}': {e}")
if new_count:
print(f"[{show_name}] Downloaded {new_count} new episode(s).")
else:
print(f"[{show_name}] No new episodes.")
def list_shows(detail=False):
"""List all registered shows."""
db = get_db()
rows = db.execute("SELECT name, slug, feed_url, opml_import FROM shows ORDER BY name").fetchall()
if not rows:
print("No shows registered.")
db.close()
return
print(f"{'Show Name':<40} {'Slug':<30} {'Protected':<10}")
print("-" * 80)
for name, slug, feed_url, prot in rows:
prot_str = "yes" if prot else "no"
print(f"{name:<40} {slug:<30} {prot_str:<10}")
if detail:
print("\nFeed URLs:")
for name, slug, feed_url, prot in rows:
print(f" {slug}: {feed_url}")
db.close()
def main():
if "--delete-show" in sys.argv:
idx = sys.argv.index("--delete-show")
if idx + 1 < len(sys.argv):
delete_show(sys.argv[idx + 1])
else:
print("Usage: fetch_podcasts.py --delete-show <slug>")
sys.exit(1)
return
if "--add-show" in sys.argv:
idx = sys.argv.index("--add-show")
if idx + 1 < len(sys.argv):
add_show(sys.argv[idx + 1])
else:
print("Usage: fetch_podcasts.py --add-show <feed-url>")
sys.exit(1)
return
if "--import-opml" in sys.argv:
idx = sys.argv.index("--import-opml")
if idx + 1 < len(sys.argv):
import_opml_file(sys.argv[idx + 1])
else:
print("Usage: fetch_podcasts.py --import-opml <path-to-file.opml>")
sys.exit(1)
return
if "--list-shows" in sys.argv:
list_shows(detail="--detail" in sys.argv)
return
# Normal run: optionally sync gpodder, then fetch all registered shows
cfg = load_config()
gp = cfg.get("gpodder", {})
if gp.get("enable", False):
print("--- Syncing subscriptions from gpodder.net ---")
try:
sync_gpoddernet()
except SystemExit:
raise
except Exception as e:
print(f"! gpodder sync failed (continuing with existing shows): {e}")
db = get_db()
shows = db.execute("SELECT name, feed_url, slug FROM shows").fetchall()
if not shows:
print("No shows registered. Import an OPML file, add a show, or enable gpodder sync.")
db.close()
return
print(f"--- Fetching episodes for {len(shows)} show(s) ---")
for show_name, feed_url, slug in shows:
try:
fetch_feed(show_name, feed_url, slug, db)
except Exception as e:
print(f"[{show_name}] FAILED: {e}")
db.close()
if __name__ == "__main__":
main()

482
fetch_podcasts.rb Executable file
View file

@ -0,0 +1,482 @@
#!/usr/bin/env jruby
# frozen_string_literal: true
#
# fetch_podcasts.rb - Fetch podcast episodes from RSS feeds, manage subscriptions,
# and download audio. JRuby-compatible (Ruby 3.1+ baseline).
require "nokogiri"
require "net/http"
require "uri"
require "json"
require "sqlite3"
require "fileutils"
require "time"
AUDIO_ROOT = Pathname.new(File.expand_path(__dir__))
DB_PATH = AUDIO_ROOT.join("state/subscriptions.db")
DOWNLOAD_DIR = AUDIO_ROOT.join("podcasts")
CONFIG_PATH = AUDIO_ROOT.join("config.json")
module Radio
class Fetcher
def initialize
@db = get_db
end
attr_reader :db
def load_config
JSON.parse(File.read(CONFIG_PATH))
end
def get_db
FileUtils.mkdir_p(DB_PATH.dirname)
conn = SQLite3::Database.new(DB_PATH.to_s)
conn.execute(<<~SQL)
CREATE TABLE IF NOT EXISTS shows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
feed_url TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL,
opml_import INTEGER DEFAULT 0
);
SQL
conn.execute(<<~SQL)
CREATE TABLE IF NOT EXISTS seen (
url TEXT PRIMARY KEY,
title TEXT,
show_slug TEXT,
duration_sec INTEGER DEFAULT 0,
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
SQL
# Migrate older databases lacking the new column
cols = conn.execute("PRAGMA table_info(shows)").map { |r| r[1] }
unless cols.include?("opml_import")
conn.execute("ALTER TABLE shows ADD COLUMN opml_import INTEGER DEFAULT 0")
end
conn.commit rescue nil
conn
end
def sanitize_slug(name)
slug = name.downcase.strip
slug = slug.gsub(/[^a-z0-9\-_]/, "_")
slug = slug.split("_").reject(&:empty?).join("_")
slug[0, 80]
end
# ---- Registration from OPML --------------------------------------------
def register_shows_from_opml_xml(xml_bytes, opml_import: false)
doc = Nokogiri::XML(xml_bytes)
added = updated_flag = skipped = 0
doc.xpath("//outline").each do |node|
xml_url = node["xmlUrl"].to_s
next if xml_url.empty?
otype = node["type"].to_s
next unless otype.empty? || otype == "rss"
show_name = node["text"] || node["title"] || "Unknown Show"
slug = sanitize_slug(show_name)
flag = opml_import ? 1 : 0
existing = db.get_first_row("SELECT id, opml_import FROM shows WHERE feed_url=?", xml_url)
if existing
existing_id, existing_flag = existing
if flag == 1 && existing_flag.to_i == 0
db.execute("UPDATE shows SET opml_import=1 WHERE id=?", existing_id)
updated_flag += 1
puts " Protected existing: #{show_name} (#{slug})"
else
skipped += 1
end
else
db.execute(
"INSERT INTO shows (name, feed_url, slug, opml_import) VALUES (?, ?, ?, ?)",
[show_name, xml_url, slug, flag]
)
added += 1
puts " Registered: #{show_name} -> #{slug}"
end
end
db.commit
puts " Added #{added} new show(s)." if added.positive?
puts " Upgraded #{updated_flag} show(s) to protected." if updated_flag.positive?
puts " Skipped #{skipped} duplicate(s)." if skipped.positive?
added
end
def import_opml_file(filepath)
unless File.exist?(filepath)
puts "ERROR: File not found: #{filepath}"
exit 1
end
xml_data = File.binread(filepath)
count = register_shows_from_opml_xml(xml_data, opml_import: true)
total = db.get_first_row("SELECT COUNT(*) FROM shows")[0]
puts "\nImport complete. #{count} new show(s) added. Total registered: #{total}"
ensure
db&.close
end
# ---- gPodder.net sync ----------------------------------------------------
def sync_gpoddernet
cfg = load_config
gp = cfg.fetch("gpodder", {})
unless gp.fetch("enable", false)
puts "gpodder.net sync is disabled in config.json."
return
end
unless gp["username"] && gp["password"]
puts "ERROR: gpodder.username/gpodder.password not set in config.json"
exit 1
end
url = "#{gp['host']}/subscriptions/#{gp['username']}.opml"
puts "Fetching subscriptions from #{gp['host']} for '#{gp['username']}'..."
uri = URI(url)
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
req = Net::HTTP::Get.new(uri)
req.basic_auth(gp["username"], gp["password"])
http.request(req)
end
if resp.code == "401"
puts "ERROR: Authentication failed. Check username/password in config.json."
exit 1
elsif resp.code != "200"
puts "ERROR: Unexpected response #{resp.code}: #{resp.body[0, 200]}"
exit 1
end
# Collect all feed URLs from the current subscription list
root_doc = Nokogiri::XML(resp.body)
gp_feed_urls = Set.new
root_doc.xpath("//outline").each do |node|
fu = node["xmlUrl"].to_s
gp_feed_urls.add(fu) unless fu.empty?
end
count = register_shows_from_opml_xml(resp.body, opml_import: false)
total = db.get_first_row("SELECT COUNT(*) FROM shows")[0]
prune_removed_shows(gp_feed_urls)
puts "\nSync complete. #{count} new, total registered: #{total}"
end
def prune_removed_shows(gp_feed_urls)
rows = db.execute("SELECT slug, feed_url, name FROM shows WHERE opml_import = 0")
to_remove = rows.select { |_slug, feed_url, _name| !gp_feed_urls.include?(feed_url) }
return if to_remove.empty?
to_remove.each do |slug, _feed_url, name|
puts " Removing unsubscribed show: #{name} (#{slug})"
db.execute("DELETE FROM seen WHERE show_slug=?", slug)
db.execute("DELETE FROM shows WHERE slug=?", slug)
show_dir = DOWNLOAD_DIR.join(slug)
if Dir.exist?(show_dir)
FileUtils.rm_rf(show_dir)
puts " Deleted directory: #{show_dir}"
end
end
db.commit
puts " Pruned #{to_remove.size} removed show(s)."
end
# ---- Manual management ---------------------------------------------------
def delete_show(slug)
row = db.get_first_row("SELECT name FROM shows WHERE slug=?", slug)
if row.nil?
puts "No show found with slug '#{slug}'."
return
end
name = row[0]
puts "Deleting show: #{name} (#{slug})"
db.execute("DELETE FROM seen WHERE show_slug=?", slug)
db.execute("DELETE FROM shows WHERE slug=?", slug)
db.commit
show_dir = DOWNLOAD_DIR.join(slug)
if Dir.exist?(show_dir)
FileUtils.rm_rf(show_dir)
puts "Deleted directory: #{show_dir}"
end
pls_file = AUDIO_ROOT.join("playlists", "#{slug}.pls")
if File.exist?(pls_file)
File.delete(pls_file)
puts "Deleted playlist: #{pls_file}"
end
puts "Done."
end
def add_show(feed_url)
puts "Fetching feed: #{feed_url}"
parsed = parse_feed(feed_url)
if parsed.nil?
puts "ERROR: Invalid or unreachable feed."
exit 1
end
show_name, entries = parsed
slug = sanitize_slug(show_name)
puts " Title: #{show_name}"
puts " Slug: #{slug}"
puts " Entries found: #{entries.size}"
existing = db.get_first_row("SELECT name FROM shows WHERE feed_url=?", feed_url)
if existing
puts "NOTE: Feed already registered as '#{existing[0]}'. Nothing to do."
return
end
db.execute(
"INSERT INTO shows (name, feed_url, slug, opml_import) VALUES (?, ?, ?, 1)",
[show_name, feed_url, slug]
)
db.commit
puts " Registered: #{show_name} -> #{slug}"
puts "\n--- Fetching episodes ---"
fetch_feed(show_name, feed_url, slug)
puts "\nDone. Episodes saved to: #{DOWNLOAD_DIR.join(slug)}/"
end
# ---- Core fetching ---------------------------------------------------------
def parse_feed(feed_url)
body = http_get(feed_url)
return nil if body.nil?
doc = Nokogiri::XML(body)
doc.remove_namespaces!
# Support both RSS (<channel>) and Atom (<feed>)
channel = doc.at_xpath("//channel") || doc.at_xpath("//feed")
return nil if channel.nil?
title = (channel.at_xpath("./title")&.text.presence || "Unknown Show")
entries = []
if doc.root.name == "rss"
doc.xpath("//item").each do |item|
enc = item.at_xpath(".//enclosure")
next if enc.nil?
entries << {
url: enc["url"].to_s,
type: enc["type"].to_s,
length: enc["length"].to_s,
title: (item.at_xpath("./title")&.text.presence || "untitled")
}
end
else
doc.xpath("//entry").each do |entry|
link = entry.at_xpath("./link[@rel='enclosure']") ||
entry.at_xpath("./link")
next if link.nil?
dur = entry.at_xpath(".//media:duration")
entries << {
url: link["href"].to_s,
type: link["type"].to_s,
length: dur ? dur.text : "",
title: (entry.at_xpath("./title")&.text.presence || "untitled")
}
end
end
[title, entries]
rescue StandardError => e
warn "Feed parse error for #{feed_url}: #{e.message}"
nil
end
def http_get(url, timeout: 120)
uri = URI(url)
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https",
open_timeout: 30, read_timeout: timeout) do |http|
res = http.request(Net::HTTP::Get.new(uri))
res.code == "200" ? res.body : nil
end
end
def fetch_feed(show_name, feed_url, slug)
parsed = parse_feed(feed_url)
if parsed.nil?
puts "[#{show_name}] ERROR: Could not parse feed."
return
end
_title, entries = parsed
show_dir = DOWNLOAD_DIR.join(slug)
FileUtils.mkdir_p(show_dir)
new_count = 0
entries.each do |ep|
ep_url = ep[:url]
title = ep[:title]
next if ep_url.empty?
row = db.get_first_row("SELECT 1 FROM seen WHERE url=?", ep_url)
next if row
duration_sec = parse_duration(ep[:length])
safe_title = title.gsub(/[^\w\- ]/, "_").strip[0, 120]
ext = extension_for_type(ep[:type])
filepath = show_dir.join("#{safe_title}#{ext}")
if File.exist?(filepath)
db.execute(
"INSERT OR IGNORE INTO seen (url, title, show_slug, duration_sec) VALUES (?, ?, ?, ?)",
[ep_url, title, slug, duration_sec]
)
db.commit
next
end
begin
puts "[#{show_name}] Downloading: #{title}"
data = http_get(ep_url)
if data.nil?
raise "download returned non-200"
end
File.binwrite(filepath, data)
db.execute(
"INSERT OR IGNORE INTO seen (url, title, show_slug, duration_sec) VALUES (?, ?, ?, ?)",
[ep_url, title, slug, duration_sec]
)
db.commit
new_count += 1
rescue StandardError => e
puts "[#{show_name}] FAILED to download '#{title}': #{e.message}"
end
end
if new_count.positive?
puts "[#{show_name}] Downloaded #{new_count} new episode(s)."
else
puts "[#{show_name}] No new episodes."
end
end
def parse_duration(raw)
raw = raw.to_s.strip
return 0 if raw.empty?
if raw.include?(":")
raw.split(":").last.to_i
else
raw.to_i
end
rescue StandardError
0
end
def extension_for_type(type_str)
t = type_str.to_s.downcase
".ogg" if t.include?("ogg")
".m4a" if t.include?("m4a") || t.include?("aac")
".mp3"
end
def list_shows(detail: false)
rows = db.execute("SELECT name, slug, feed_url, opml_import FROM shows ORDER BY name")
if rows.empty?
puts "No shows registered."
return
end
puts format("%-40s %-30s %-10s", "Show Name", "Slug", "Protected")
puts "-" * 80
rows.each do |name, slug, _feed_url, prot|
puts format("%-40s %-30s %-10s", name, slug, prot.to_i == 1 ? "yes" : "no")
end
if detail
puts "\nFeed URLs:"
rows.each do |_name, slug, feed_url, _prot|
puts " #{slug}: #{feed_url}"
end
end
end
def close
db&.close
end
end
end
require "set"
def main
args = ARGV.dup
f = Radio::Fetcher.new
if args.include?("--delete-show")
idx = args.index("--delete-show")
val = args[idx + 1]
if val.nil?
puts "Usage: fetch_podcasts.rb --delete-show <slug>"
exit 1
end
f.delete_show(val)
elsif args.include?("--add-show")
idx = args.index("--add-show")
val = args[idx + 1]
if val.nil?
puts "Usage: fetch_podcasts.rb --add-show <feed-url>"
exit 1
end
f.add_show(val)
elsif args.include?("--import-opml")
idx = args.index("--import-opml")
val = args[idx + 1]
if val.nil?
puts "Usage: fetch_podcasts.rb --import-opml <path-to-file.opml>"
exit 1
end
f.import_opml_file(val)
elsif args.include?("--list-shows")
f.list_shows(detail: args.include?("--detail"))
else
cfg = f.load_config
gp = cfg.fetch("gpodder", {})
if gp.fetch("enable", false)
puts "--- Syncing subscriptions from gpodder.net ---"
begin
f.sync_gpoddernet
rescue SystemExit
raise
rescue StandardError => e
puts "! gpodder sync failed (continuing with existing shows): #{e.message}"
end
end
shows = f.db.execute("SELECT name, feed_url, slug FROM shows")
if shows.empty?
puts "No shows registered. Import an OPML file, add a show, or enable gpodder sync."
else
puts "--- Fetching episodes for #{shows.size} show(s) ---"
shows.each do |show_name, feed_url, slug|
begin
f.fetch_feed(show_name, feed_url, slug)
rescue StandardError => e
puts "[#{show_name}] FAILED: #{e.message}"
end
end
end
end
ensure
f&.close
end
main

580
install_for_jruby Executable file
View file

@ -0,0 +1,580 @@
#!/bin/bash
#
# install_for_jruby - Setup for liquidsoap radio automation stack (JRuby edition)
# Target: Linux Mint 22.3 (Ubuntu 24.04 Noble based)
#
# Usage: sudo ./install_for_jruby
# Must be run from within the target directory (e.g., /srv/audio/)
#
# Detects existing Java/JRuby; installs OpenJDK 21 and JRuby 10.1.1.0 into /opt
# only if missing, integrating JRuby via update-alternatives. Installs gems one
# at a time with a raised JVM heap to avoid OOM on low-RAM hosts.
#
set -euo pipefail
# -------------------------------------------------------
# Determine install directory from script location
# -------------------------------------------------------
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
INSTALL_DIR="$(dirname "$SCRIPT_PATH")"
SERVICE_NAME="$(basename "$INSTALL_DIR")"
LIQUIDSOAP_USER="liquidsoap"
JRUBY_VERSION="10.1.1.0"
JRUBY_HOME="/opt/jruby-${JRUBY_VERSION}"
GEM_HOME_LOCAL="$INSTALL_DIR/.gems"
echo "=== Radio Automation Installer (JRuby) ==="
echo "Install directory: $INSTALL_DIR"
echo "Service name: $SERVICE_NAME"
echo ""
# -------------------------------------------------------
# 0. Validate environment
# -------------------------------------------------------
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: This script must be run as root (sudo)."
exit 1
fi
if [ ! -d "$INSTALL_DIR" ]; then
echo "ERROR: Install directory $INSTALL_DIR does not exist."
exit 1
fi
if [ ! -w "$INSTALL_DIR" ]; then
echo "ERROR: Cannot write to $INSTALL_DIR. Check permissions."
exit 1
fi
REQUIRED_DIRS=(
"music"
"podcasts"
"jingles"
"announcements"
"playlists"
"state"
"logs"
)
# -------------------------------------------------------
# Helper: prompt with default value
# -------------------------------------------------------
prompt() {
local var_name="$1"
local prompt_text="$2"
local default_value="${3:-}"
local hide_input="${4:-false}"
if [ -n "$default_value" ]; then
read -rp "$prompt_text [$default_value]: " input
if [ -z "$input" ]; then
eval "$var_name=$default_value"
else
eval "$var_name=\$input"
fi
else
if [ "$hide_input" = "true" ]; then
read -rsp "$prompt_text: " input
echo ""
eval "$var_name=\$input"
else
read -rp "$prompt_text: " input
eval "$var_name=\$input"
fi
fi
}
prompt_confirm() {
local var_name="$1"
local prompt_text="$2"
local default_value="${3:-no}"
read -rp "$prompt_text [$default_value]: " input
if [ -z "$input" ]; then
input="$default_value"
fi
case "$input" in
[Yy]* ) eval "$var_name=true" ;;
* ) eval "$var_name=false" ;;
esac
}
# -------------------------------------------------------
# 1. Interactive configuration
# -------------------------------------------------------
CONFIG_FILE="$INSTALL_DIR/config.json"
EXISTING_CONFIG=false
if [ -f "$CONFIG_FILE" ]; then
EXISTING_CONFIG=true
echo "--- Loading existing config.json for defaults ---"
ICECAST_HOST_DEFAULT=$(jq -r '.icecast.host // "localhost"' "$CONFIG_FILE")
ICECAST_PORT_DEFAULT=$(jq -r '.icecast.port // 7777' "$CONFIG_FILE")
ICECAST_MOUNT_DEFAULT=$(jq -r '.icecast.mount // "/audio.mp3"' "$CONFIG_FILE")
ICECAST_USERNAME_DEFAULT=$(jq -r '.icecast.username // "source"' "$CONFIG_FILE")
GPODDER_ENABLE_DEFAULT=$(jq -r 'if .gpodder.enable == true then "yes" else "no" end' "$CONFIG_FILE")
GPODDER_HOST_DEFAULT=$(jq -r '.gpodder.host // "https://gpodder.net"' "$CONFIG_FILE")
GPODDER_USERNAME_DEFAULT=$(jq -r '.gpodder.username // ""' "$CONFIG_FILE")
else
ICECAST_HOST_DEFAULT="localhost"
ICECAST_PORT_DEFAULT="7777"
ICECAST_MOUNT_DEFAULT="/audio.mp3"
ICECAST_USERNAME_DEFAULT="source"
GPODDER_ENABLE_DEFAULT="no"
GPODDER_HOST_DEFAULT="https://gpodder.net"
GPODDER_USERNAME_DEFAULT=""
fi
echo ""
echo "--- Icecast Settings ---"
echo ""
ICECAST_HOST=""
ICECAST_PORT=""
ICECAST_MOUNT=""
ICECAST_USERNAME=""
ICECAST_PASSWORD=""
while true; do
prompt ICECAST_HOST "Icecast host" "$ICECAST_HOST_DEFAULT"
[[ "$ICECAST_HOST" =~ ^[a-zA-Z0-9._-]+$ ]] && break
echo "Invalid hostname. Try again."
done
while true; do
prompt ICECAST_PORT "Source port" "$ICECAST_PORT_DEFAULT"
[[ "$ICECAST_PORT" =~ ^[0-9]{1,5}$ ]] && (( ICECAST_PORT >= 1 )) && (( ICECAST_PORT <= 65535 )) && break
echo "Port must be a number between 1 and 65535. Try again."
done
while true; do
prompt ICECAST_MOUNT "Mount point" "$ICECAST_MOUNT_DEFAULT"
[[ "$ICECAST_MOUNT" == /* ]] && break
echo "Mount point must start with /. Try again."
done
prompt ICECAST_USERNAME "Source username" "$ICECAST_USERNAME_DEFAULT"
while true; do
prompt ICECAST_PASSWORD "Source password" "" "true"
[ -n "$ICECAST_PASSWORD" ] && break
echo "Password cannot be empty. Try again."
done
echo ""
echo "--- gPodder.net Sync Settings ---"
echo ""
GPODDER_ENABLE=false
if [ "$GPODDER_ENABLE_DEFAULT" = "yes" ]; then
prompt_confirm GPODDER_ENABLE "Enable gPodder.net subscription sync?" "yes"
else
prompt_confirm GPODDER_ENABLE "Enable gPodder.net subscription sync?" "no"
fi
GPODDER_HOST=""
GPODDER_USERNAME=""
GPODDER_PASSWORD=""
if [ "$GPODDER_ENABLE" = "true" ]; then
prompt GPODDER_HOST "gPodder host" "$GPODDER_HOST_DEFAULT"
while true; do
prompt GPODDER_USERNAME "gPodder username/email" "$GPODDER_USERNAME_DEFAULT"
[ -n "$GPODDER_USERNAME" ] && break
echo "Username cannot be empty. Try again."
done
while true; do
prompt GPODDER_PASSWORD "gPodder password" "" "true"
[ -n "$GPODDER_PASSWORD" ] && break
echo "Password cannot be empty. Try again."
done
fi
echo ""
echo "--- Summary ---"
echo " Icecast: ${ICECAST_HOST}:${ICECAST_PORT}${ICECAST_MOUNT} (user: ${ICECAST_USERNAME})"
echo " gPodder: $( [ "$GPODDER_ENABLE" = "true" ] && echo "enabled (${GPODDER_USERNAME}@${GPODDER_HOST})" || echo "disabled" )"
echo ""
CONFIRM_INSTALL=false
prompt_confirm CONFIRM_INSTALL "Proceed with these settings?" "yes"
if [ "$CONFIRM_INSTALL" != "true" ]; then
echo "Aborted by user."
exit 0
fi
# -------------------------------------------------------
# 2. Base system packages (always needed regardless of Java state)
# -------------------------------------------------------
echo ""
echo "--- Installing base system packages ---"
apt-get update
apt-get install -y \
liquidsoap \
icecast2 \
jq \
curl \
ca-certificates \
unzip
# -------------------------------------------------------
# 3. Detect / install Java (OpenJDK 21 headless)
# -------------------------------------------------------
echo ""
echo "--- Checking Java ---"
JAVA_OK=false
if command -v java >/dev/null 2>&1; then
JAVA_VER_LINE=$(java -version 2>&1 | head -1)
# Extract major version: handles both '"21.0.x"' and '1.8.0_xxx' styles
JAVA_MAJOR=$(echo "$JAVA_VER_LINE" | sed -nE 's/.*"([0-9]+)(\.[0-9]+)?".*/\1/p')
if [ -n "$JAVA_MAJOR" ] && [ "$JAVA_MAJOR" -ge 21 ]; then
JAVA_OK=true
echo " Found suitable Java ($JAVA_VER_LINE)"
else
echo " Java found but version too old ($JAVA_VER_LINE); installing OpenJDK 21."
fi
else
echo " No Java detected; installing OpenJDK 21."
fi
if [ "$JAVA_OK" != "true" ]; then
apt-get install -y openjdk-21-jre-headless
echo " Installed OpenJDK 21."
fi
# -------------------------------------------------------
# 4. Detect / install JRuby
# -------------------------------------------------------
echo ""
echo "--- Checking JRuby ---"
JRUBY_FOUND=""
# Preferred: our pinned home already present
if [ -x "$JRUBY_HOME/bin/jruby" ]; then
JRUBY_FOUND="$JRUBY_HOME/bin/jruby"
echo " Found JRuby at $JRUBY_HOME"
fi
# Fallback: any jruby resolvable on PATH (system, other /opt version, etc.)
if [ -z "$JRUBY_FOUND" ] && command -v jruby >/dev/null 2>&1; then
DETECTED_JRUBY="$(command -v jruby)"
DETECTED_JRUBY="$(readlink -f "$DETECTED_JRUBY")"
JRUBY_FOUND="$DETECTED_JRUBY"
echo " Found existing JRuby on PATH: $DETECTED_JRUBY"
echo " ($( "$DETECTED_JRUBY" -v 2>/dev/null | head -1 ))"
fi
if [ -z "$JRUBY_FOUND" ]; then
echo " No JRuby detected; installing JRuby ${JRUBY_VERSION} to /opt ..."
JRUBY_TARBALL="/tmp/jruby-dist-${JRUBY_VERSION}-bin.tar.gz"
# Post-9.1.14.0 releases are distributed via Maven Central as jruby-dist-*
curl -fsSL "https://repo1.maven.org/maven2/org/jruby/jruby-dist/${JRUBY_VERSION}/jruby-dist-${JRUBY_VERSION}-bin.tar.gz" -o "$JRUBY_TARBALL"
mkdir -p /opt
tar -xzf "$JRUBY_TARBALL" -C /opt
rm -f "$JRUBY_TARBALL"
ln -sfn "$JRUBY_HOME" /opt/jruby-current
JRUBY_FOUND="$JRUBY_HOME/bin/jruby"
echo " Installed JRuby to $JRUBY_HOME"
fi
# Derive the effective JRuby home from whichever binary we ended up using
EFF_JRUBY_BIN="$JRUBY_FOUND"
EFF_JRUBY_HOME="$(dirname "$(dirname "$EFF_JRUBY_BIN")")"
export PATH="$EFF_JRUBY_HOME/bin:$PATH"
# -------------------------------------------------------
# 5. Integrate JRuby with the system via update-alternatives
# -------------------------------------------------------
echo "--- Integrating JRuby via update-alternatives ---"
update-alternatives --install /usr/local/bin/jruby jruby "$EFF_JRUBY_BIN" 100
# Also expose bundle/gem/rake under alternatives so they resolve system-wide
for tool in bundle gem rake irb; do
if [ -x "$EFF_JRUBY_HOME/bin/$tool" ]; then
update-alternatives --install "/usr/local/bin/$tool" "$tool" "$EFF_JRUBY_HOME/bin/$tool" 100
fi
done
echo " Registered jruby -> $EFF_JRUBY_BIN (priority 100)"
# Sanity-check that the integrated binary actually runs
if ! "$EFF_JRUBY_BIN" -v >/dev/null 2>&1; then
echo "ERROR: Integrated JRuby binary failed to execute. Aborting."
exit 1
fi
echo " Verified: $("${EFF_JRUBY_BIN}" -v 2>/dev/null | head -1)"
# -------------------------------------------------------
# 6. Install gems into a project-local gem home (one at a time)
# -------------------------------------------------------
echo "--- Installing Ruby gems ---"
export GEM_HOME="$GEM_HOME_LOCAL"
export GEM_PATH="$GEM_HOME_LOCAL"
# Raise the JVM heap for gem operations. JRuby defaults to ~500MB, which is
# too small when bundler resolves multiple gems at once on a low-RAM host.
export JRUBY_OPTS="-J-Xmx1g -J-Xss512k"
install_gem() {
local name="$1"
echo " Installing gem: $name"
if ! "$EFF_JRUBY_BIN" -S gem install "$name" --no-document; then
echo "ERROR: Failed to install gem '$name'."
exit 1
fi
}
# Install bundler first (needed to drive bundle install), then each dependency
# separately so a single OOM can't take down the whole set.
install_gem bundler
install_gem nokogiri
install_gem sqlite3
install_gem json
# Lock versions against the Gemfile now that every gem is present.
(cd "$INSTALL_DIR" && \
GEM_HOME="$GEM_HOME_LOCAL" GEM_PATH="$GEM_HOME_LOCAL" \
JRUBY_OPTS="$JRUBY_OPTS" \
"$EFF_JRUBY_BIN" -S bundle install --quiet)
echo " Gems installed to $GEM_HOME_LOCAL"
# -------------------------------------------------------
# 7. Create and verify directory structure
# -------------------------------------------------------
echo "--- Creating directory structure ---"
MISSING=()
for dir in "${REQUIRED_DIRS[@]}"; do
full_path="$INSTALL_DIR/$dir"
if [ ! -d "$full_path" ]; then
mkdir -p "$full_path"
MISSING+=("$dir")
fi
done
if [ ${#MISSING[@]} -gt 0 ]; then
echo " Created: ${MISSING[*]}"
else
echo " All directories already present."
fi
FAILED=()
for dir in "${REQUIRED_DIRS[@]}"; do
if [ ! -d "$INSTALL_DIR/$dir" ]; then
FAILED+=("$dir")
fi
done
if [ ${#FAILED[@]} -gt 0 ]; then
echo "ERROR: Failed to create directories: ${FAILED[*]}"
exit 1
fi
# -------------------------------------------------------
# 8. Generate config.json from collected settings
# -------------------------------------------------------
echo "--- Generating config.json ---"
cat > "$CONFIG_FILE" << EOF
{
"icecast": {
"host": "${ICECAST_HOST}",
"port": ${ICECAST_PORT},
"mount": "${ICECAST_MOUNT}",
"username": "${ICECAST_USERNAME}",
"password": "${ICECAST_PASSWORD}"
},
"gpodder": {
"enable": ${GPODDER_ENABLE},
"host": "${GPODDER_HOST}",
"username": "${GPODDER_USERNAME}",
"password": "${GPODDER_PASSWORD}"
}
}
EOF
chmod 600 "$CONFIG_FILE"
echo " Written: $CONFIG_FILE"
# -------------------------------------------------------
# 9. Create schedule.txt template (if not present)
# -------------------------------------------------------
SCHEDULE_FILE="$INSTALL_DIR/schedule.txt"
if [ ! -f "$SCHEDULE_FILE" ]; then
echo "--- Creating schedule.txt template ---"
cat > "$SCHEDULE_FILE" << 'EOF'
# Radio Schedule
# Format: min hour dom mon dow TYPE TARGET [RUNLENGTH_SECONDS]
# TYPE: show | stream
# RUNLENGTH required for stream entries, ignored for show entries
#
# Examples:
# 0 8 * * 2 show hardcore_history
# 0 6 * * 1 stream http://example.org:8000/live.mp3 3600
EOF
else
echo "--- schedule.txt already exists, skipping ---"
fi
# -------------------------------------------------------
# 10. Verify required project files exist
# -------------------------------------------------------
echo "--- Verifying project files ---"
PROJECT_FILES=("station.liq" "fetch_podcasts.rb" "update_playlists.rb" "Gemfile")
FILE_ERRORS=()
for f in "${PROJECT_FILES[@]}"; do
if [ ! -f "$INSTALL_DIR/$f" ]; then
FILE_ERRORS+=("$f")
else
echo " Found: $f"
fi
done
if [ ${#FILE_ERRORS[@]} -gt 0 ]; then
echo "WARNING: Missing expected files: ${FILE_ERRORS[*]}"
echo " The service will not start until these are in place."
fi
# -------------------------------------------------------
# 11. Create systemd service named after the directory
# -------------------------------------------------------
echo "--- Creating systemd service: ${SERVICE_NAME}.service ---"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF
[Unit]
Description=Liquidsoap radio automation (${SERVICE_NAME})
After=network.target icecast2.service
[Service]
Type=simple
User=${LIQUIDSOAP_USER}
WorkingDirectory=${INSTALL_DIR}
Environment=GEM_HOME=${GEM_HOME_LOCAL}
Environment=GEM_PATH=${GEM_HOME_LOCAL}
ExecStart=/usr/bin/liquidsoap ${INSTALL_DIR}/station.liq
Restart=on-failure
RestartSec=5
StandardOutput=append:${INSTALL_DIR}/logs/liquidsoap.log
StandardError=append:${INSTALL_DIR}/logs/liquidsoap.log
[Install]
WantedBy=multi-user.target
EOF
# -------------------------------------------------------
# 12. Create cron jobs (hourly fetch + hourly playlist update)
# -------------------------------------------------------
echo "--- Setting up cron jobs ---"
# Cron runs with a minimal PATH, so reference the absolute integrated path.
RUBY_RUN="cd $INSTALL_DIR && GEM_HOME=$GEM_HOME_LOCAL GEM_PATH=$GEM_HOME_LOCAL /usr/local/bin/jruby -S bundle exec"
FETCH_CRON="0 * * * * $RUBY_RUN fetch_podcasts.rb >> $INSTALL_DIR/logs/fetch.log 2>&1"
UPDATE_CRON="30 * * * * $RUBY_RUN update_playlists.rb >> $INSTALL_DIR/logs/update.log 2>&1"
EXISTING_CRON=$(crontab -l -u "$LIQUIDSOAP_USER" 2>/dev/null || true)
CLEANED_CRON=$(echo "$EXISTING_CRON" | grep -v "fetch_podcasts.rb\|update_playlists.rb" || true)
if ! echo "$EXISTING_CRON" | grep -q "fetch_podcasts.rb"; then
CLEANED_CRON="${CLEANED_CRON:+$CLEANED_CRON
}$FETCH_CRON"
echo " Added: fetch_podcasts.rb (hourly)"
else
echo " Skipped: fetch_podcasts.rb job already exists"
fi
if ! echo "$EXISTING_CRON" | grep -q "update_playlists.rb"; then
CLEANED_CRON="${CLEANED_CRON:+$CLEANED_CRON
}$UPDATE_CRON"
echo " Added: update_playlists.rb (hourly at :30)"
else
echo " Skipped: update_playlists.rb job already exists"
fi
echo "$CLEANED_CRON" | crontab -u "$LIQUIDSOAP_USER" -
# -------------------------------------------------------
# 13. Permissions
# -------------------------------------------------------
echo "--- Setting ownership and permissions ---"
id -u "$LIQUIDSOAP_USER" &>/dev/null || useradd --system --shell /usr/sbin/nologin "$LIQUIDSOAP_USER"
chown -R "${LIQUIDSOAP_USER}:${LIQUIDSOAP_USER}" "$INSTALL_DIR"
chmod 700 "$INSTALL_DIR/state"
chmod 700 "$INSTALL_DIR/logs"
systemctl daemon-reload
# -------------------------------------------------------
# 14. Final verification
# -------------------------------------------------------
echo ""
echo "=== Final Verification ==="
ALL_OK=true
for dir in "${REQUIRED_DIRS[@]}"; do
if [ -d "$INSTALL_DIR/$dir" ] && [ -r "$INSTALL_DIR/$dir" ] && [ -w "$INSTALL_DIR/$dir" ]; then
printf " [OK] %-20s\n" "$dir/"
else
printf " [FAIL] %-20s (missing or inaccessible)\n" "$dir/"
ALL_OK=false
fi
done
if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then
echo " [OK] Service file: ${SERVICE_NAME}.service"
else
echo " [FAIL] Service file not created"
ALL_OK=false
fi
if [ -f "$CONFIG_FILE" ]; then
if jq empty "$CONFIG_FILE" 2>/dev/null; then
echo " [OK] Config: config.json (valid JSON)"
else
echo " [FAIL] Config: config.json (invalid JSON)"
ALL_OK=false
fi
else
echo " [FAIL] Config file missing"
ALL_OK=false
fi
if [ -f "$SCHEDULE_FILE" ]; then
echo " [OK] Schedule: schedule.txt"
else
echo " [FAIL] Schedule file missing"
ALL_OK=false
fi
if command -v java >/dev/null 2>&1; then
echo " [OK] Java: $(java -version 2>&1 | head -1)"
else
echo " [FAIL] Java not available"
ALL_OK=false
fi
if [ -x /usr/local/bin/jruby ] && /usr/local/bin/jruby -v >/dev/null 2>&1; then
echo " [OK] JRuby: $(/usr/local/bin/jruby -v 2>/dev/null | head -1)"
else
echo " [FAIL] Integrated JRuby (/usr/local/bin/jruby) not runnable"
ALL_OK=false
fi
echo ""
if [ "$ALL_OK" = true ]; then
echo "=== Installation Complete ==="
else
echo "=== Installation Finished With Errors ==="
echo "Review the [FAIL] items above before starting the service."
fi
echo ""
echo "Next steps:"
echo " 1. Place your music in $INSTALL_DIR/music/"
echo " 2. Edit $SCHEDULE_FILE with your show/stream schedule"
echo " 3. Review/edit $INSTALL_DIR/station.liq"
echo " 4. Test: sudo systemctl start icecast2 && sudo systemctl start ${SERVICE_NAME}"
echo " 5. Check logs: tail -f $INSTALL_DIR/logs/liquidsoap.log"
echo ""
echo "Manage shows (run as the liquidsoap user):"
RB_PREFIX="sudo -u liquidsoap env GEM_HOME=$GEM_HOME_LOCAL GEM_PATH=$GEM_HOME_LOCAL /usr/local/bin/jruby -S bundle exec"
echo " List: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --list-shows --detail"
echo " Delete: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --delete-show <slug>"
echo " Import OPML: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --import-opml <file.opml>"
echo " Add show: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --add-show <feed-url>"
echo " JSON state: $RB_PREFIX $INSTALL_DIR/update_playlists.rb --json"

459
install_for_python Executable file
View file

@ -0,0 +1,459 @@
#!/bin/bash
#
# install_for_python - Setup for liquidsoap radio automation stack
# Target: Linux Mint 22.3 (Ubuntu 24.04 Noble based)
#
# Usage: sudo ./install_for_python
# Must be run from within the target directory (e.g., /srv/audio/)
#
set -euo pipefail
# -------------------------------------------------------
# Determine install directory from script location
# -------------------------------------------------------
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
INSTALL_DIR="$(dirname "$SCRIPT_PATH")"
SERVICE_NAME="$(basename "$INSTALL_DIR")"
LIQUIDSOAP_USER="liquidsoap"
echo "=== Radio Automation Installer ==="
echo "Install directory: $INSTALL_DIR"
echo "Service name: $SERVICE_NAME"
echo ""
# -------------------------------------------------------
# 0. Validate environment
# -------------------------------------------------------
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: This script must be run as root (sudo)."
exit 1
fi
if [ ! -d "$INSTALL_DIR" ]; then
echo "ERROR: Install directory $INSTALL_DIR does not exist."
exit 1
fi
if [ ! -w "$INSTALL_DIR" ]; then
echo "ERROR: Cannot write to $INSTALL_DIR. Check permissions."
exit 1
fi
REQUIRED_DIRS=(
"music"
"podcasts"
"jingles"
"announcements"
"playlists"
"state"
"logs"
)
# -------------------------------------------------------
# Helper: prompt with default value
# -------------------------------------------------------
prompt() {
local var_name="$1"
local prompt_text="$2"
local default_value="${3:-}"
local hide_input="${4:-false}"
if [ -n "$default_value" ]; then
read -rp "$prompt_text [$default_value]: " input
if [ -z "$input" ]; then
eval "$var_name=$default_value"
else
eval "$var_name=\$input"
fi
else
if [ "$hide_input" = "true" ]; then
read -rsp "$prompt_text: " input
echo ""
eval "$var_name=\$input"
else
read -rp "$prompt_text: " input
eval "$var_name=\$input"
fi
fi
}
prompt_confirm() {
local var_name="$1"
local prompt_text="$2"
local default_value="${3:-no}"
read -rp "$prompt_text [$default_value]: " input
if [ -z "$input" ]; then
input="$default_value"
fi
case "$input" in
[Yy]* ) eval "$var_name=true" ;;
* ) eval "$var_name=false" ;;
esac
}
# -------------------------------------------------------
# 1. Interactive configuration
# -------------------------------------------------------
CONFIG_FILE="$INSTALL_DIR/config.json"
# Load existing config as defaults if present
EXISTING_CONFIG=false
if [ -f "$CONFIG_FILE" ]; then
EXISTING_CONFIG=true
echo "--- Loading existing config.json for defaults ---"
ICECAST_HOST_DEFAULT=$(jq -r '.icecast.host // "localhost"' "$CONFIG_FILE")
ICECAST_PORT_DEFAULT=$(jq -r '.icecast.port // 7777' "$CONFIG_FILE")
ICECAST_MOUNT_DEFAULT=$(jq -r '.icecast.mount // "/audio.mp3"' "$CONFIG_FILE")
ICECAST_USERNAME_DEFAULT=$(jq -r '.icecast.username // "source"' "$CONFIG_FILE")
GPODDER_ENABLE_DEFAULT=$(jq -r 'if .gpodder.enable == true then "yes" else "no" end' "$CONFIG_FILE")
GPODDER_HOST_DEFAULT=$(jq -r '.gpodder.host // "https://gpodder.net"' "$CONFIG_FILE")
GPODDER_USERNAME_DEFAULT=$(jq -r '.gpodder.username // ""' "$CONFIG_FILE")
else
ICECAST_HOST_DEFAULT="localhost"
ICECAST_PORT_DEFAULT="7777"
ICECAST_MOUNT_DEFAULT="/audio.mp3"
ICECAST_USERNAME_DEFAULT="source"
GPODDER_ENABLE_DEFAULT="no"
GPODDER_HOST_DEFAULT="https://gpodder.net"
GPODDER_USERNAME_DEFAULT=""
fi
echo ""
echo "--- Icecast Settings ---"
echo ""
ICECAST_HOST=""
ICECAST_PORT=""
ICECAST_MOUNT=""
ICECAST_USERNAME=""
ICECAST_PASSWORD=""
while true; do
prompt ICECAST_HOST "Icecast host" "$ICECAST_HOST_DEFAULT"
[[ "$ICECAST_HOST" =~ ^[a-zA-Z0-9._-]+$ ]] && break
echo "Invalid hostname. Try again."
done
while true; do
prompt ICECAST_PORT "Source port" "$ICECAST_PORT_DEFAULT"
[[ "$ICECAST_PORT" =~ ^[0-9]{1,5}$ ]] && (( ICECAST_PORT >= 1 )) && (( ICECAST_PORT <= 65535 )) && break
echo "Port must be a number between 1 and 65535. Try again."
done
while true; do
prompt ICECAST_MOUNT "Mount point" "$ICECAST_MOUNT_DEFAULT"
[[ "$ICECAST_MOUNT" == /* ]] && break
echo "Mount point must start with /. Try again."
done
prompt ICECAST_USERNAME "Source username" "$ICECAST_USERNAME_DEFAULT"
while true; do
prompt ICECAST_PASSWORD "Source password" "" "true"
[ -n "$ICECAST_PASSWORD" ] && break
echo "Password cannot be empty. Try again."
done
echo ""
echo "--- gPodder.net Sync Settings ---"
echo ""
GPODDER_ENABLE=false
if [ "$GPODDER_ENABLE_DEFAULT" = "yes" ]; then
prompt_confirm GPODDER_ENABLE "Enable gPodder.net subscription sync?" "yes"
else
prompt_confirm GPODDER_ENABLE "Enable gPodder.net subscription sync?" "no"
fi
GPODDER_HOST=""
GPODDER_USERNAME=""
GPODDER_PASSWORD=""
if [ "$GPODDER_ENABLE" = "true" ]; then
prompt GPODDER_HOST "gPodder host" "$GPODDER_HOST_DEFAULT"
while true; do
prompt GPODDER_USERNAME "gPodder username/email" "$GPODDER_USERNAME_DEFAULT"
[ -n "$GPODDER_USERNAME" ] && break
echo "Username cannot be empty. Try again."
done
while true; do
prompt GPODDER_PASSWORD "gPodder password" "" "true"
[ -n "$GPODDER_PASSWORD" ] && break
echo "Password cannot be empty. Try again."
done
fi
echo ""
echo "--- Summary ---"
echo " Icecast: ${ICECAST_HOST}:${ICECAST_PORT}${ICECAST_MOUNT} (user: ${ICECAST_USERNAME})"
echo " gPodder: $( [ "$GPODDER_ENABLE" = "true" ] && echo "enabled (${GPODDER_USERNAME}@${GPODDER_HOST})" || echo "disabled" )"
echo ""
CONFIRM_INSTALL=false
prompt_confirm CONFIRM_INSTALL "Proceed with these settings?" "yes"
if [ "$CONFIRM_INSTALL" != "true" ]; then
echo "Aborted by user."
exit 0
fi
# -------------------------------------------------------
# 2. System packages
# -------------------------------------------------------
echo ""
echo "--- Installing system packages ---"
apt-get update
apt-get install -y \
liquidsoap \
icecast2 \
jq \
python3 \
python3-pip \
python3-venv \
ffmpeg \
lame \
libtag1-dev \
curl \
ca-certificates
# -------------------------------------------------------
# 3. Python virtual environment
# -------------------------------------------------------
echo "--- Setting up Python virtualenv ---"
VENV_DIR="$INSTALL_DIR/.venv"
python3 -m venv "$VENV_DIR"
"$VENV_DIR/bin/pip" install --upgrade pip
"$VENV_DIR/bin/pip" install \
feedparser \
requests \
mutagen
PYTHON_BIN="$VENV_DIR/bin/python3"
# -------------------------------------------------------
# 4. Create and verify directory structure
# -------------------------------------------------------
echo "--- Creating directory structure ---"
MISSING=()
for dir in "${REQUIRED_DIRS[@]}"; do
full_path="$INSTALL_DIR/$dir"
if [ ! -d "$full_path" ]; then
mkdir -p "$full_path"
MISSING+=("$dir")
fi
done
if [ ${#MISSING[@]} -gt 0 ]; then
echo " Created: ${MISSING[*]}"
else
echo " All directories already present."
fi
FAILED=()
for dir in "${REQUIRED_DIRS[@]}"; do
if [ ! -d "$INSTALL_DIR/$dir" ]; then
FAILED+=("$dir")
fi
done
if [ ${#FAILED[@]} -gt 0 ]; then
echo "ERROR: Failed to create directories: ${FAILED[*]}"
exit 1
fi
# -------------------------------------------------------
# 5. Generate config.json from collected settings
# -------------------------------------------------------
echo "--- Generating config.json ---"
cat > "$CONFIG_FILE" << EOF
{
"icecast": {
"host": "${ICECAST_HOST}",
"port": ${ICECAST_PORT},
"mount": "${ICECAST_MOUNT}",
"username": "${ICECAST_USERNAME}",
"password": "${ICECAST_PASSWORD}"
},
"gpodder": {
"enable": ${GPODDER_ENABLE},
"host": "${GPODDER_HOST}",
"username": "${GPODDER_USERNAME}",
"password": "${GPODDER_PASSWORD}"
}
}
EOF
chmod 600 "$CONFIG_FILE"
echo " Written: $CONFIG_FILE"
# -------------------------------------------------------
# 6. Create schedule.txt template (if not present)
# -------------------------------------------------------
SCHEDULE_FILE="$INSTALL_DIR/schedule.txt"
if [ ! -f "$SCHEDULE_FILE" ]; then
echo "--- Creating schedule.txt template ---"
cat > "$SCHEDULE_FILE" << 'EOF'
# Radio Schedule
# Format: min hour dom mon dow TYPE TARGET [RUNLENGTH_SECONDS]
# TYPE: show | stream
# RUNLENGTH required for stream entries, ignored for show entries
#
# Examples:
# 0 8 * * 2 show hardcore_history
# 0 6 * * 1 stream http://example.org:8000/live.mp3 3600
EOF
else
echo "--- schedule.txt already exists, skipping ---"
fi
# -------------------------------------------------------
# 7. Verify required project files exist
# -------------------------------------------------------
echo "--- Verifying project files ---"
PROJECT_FILES=("station.liq" "fetch_podcasts.py" "update_playlists.py")
FILE_ERRORS=()
for f in "${PROJECT_FILES[@]}"; do
if [ ! -f "$INSTALL_DIR/$f" ]; then
FILE_ERRORS+=("$f")
else
echo " Found: $f"
fi
done
if [ ${#FILE_ERRORS[@]} -gt 0 ]; then
echo "WARNING: Missing expected files: ${FILE_ERRORS[*]}"
echo " The service will not start until these are in place."
fi
# -------------------------------------------------------
# 8. Create systemd service named after the directory
# -------------------------------------------------------
echo "--- Creating systemd service: ${SERVICE_NAME}.service ---"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF
[Unit]
Description=Liquidsoap radio automation (${SERVICE_NAME})
After=network.target icecast2.service
[Service]
Type=simple
User=${LIQUIDSOAP_USER}
WorkingDirectory=${INSTALL_DIR}
ExecStart=/usr/bin/liquidsoap ${INSTALL_DIR}/station.liq
Restart=on-failure
RestartSec=5
StandardOutput=append:${INSTALL_DIR}/logs/liquidsoap.log
StandardError=append:${INSTALL_DIR}/logs/liquidsoap.log
[Install]
WantedBy=multi-user.target
EOF
# -------------------------------------------------------
# 9. Create cron jobs (hourly fetch + hourly playlist update)
# -------------------------------------------------------
echo "--- Setting up cron jobs ---"
FETCH_CRON="0 * * * * cd $INSTALL_DIR && $PYTHON_BIN fetch_podcasts.py >> $INSTALL_DIR/logs/fetch.log 2>&1"
UPDATE_CRON="30 * * * * cd $INSTALL_DIR && $PYTHON_BIN update_playlists.py >> $INSTALL_DIR/logs/update.log 2>&1"
# Pull existing crontab (empty if none)
EXISTING_CRON=$(crontab -l -u "$LIQUIDSOAP_USER" 2>/dev/null || true)
# Remove any stale entries for these scripts to avoid duplicates on re-runs
CLEANED_CRON=$(echo "$EXISTING_CRON" | grep -v "fetch_podcasts.py\|update_playlists.py" || true)
# Add fetch job if not present
if ! echo "$EXISTING_CRON" | grep -q "fetch_podcasts.py"; then
CLEANED_CRON="${CLEANED_CRON:+$CLEANED_CRON
}$FETCH_CRON"
echo " Added: fetch_podcasts.py (hourly)"
else
echo " Skipped: fetch_podcasts.py job already exists"
fi
# Add update job if not present
if ! echo "$EXISTING_CRON" | grep -q "update_playlists.py"; then
CLEANED_CRON="${CLEANED_CRON:+$CLEANED_CRON
}$UPDATE_CRON"
echo " Added: update_playlists.py (hourly at :30)"
else
echo " Skipped: update_playlists.py job already exists"
fi
# Write back the combined crontab
echo "$CLEANED_CRON" | crontab -u "$LIQUIDSOAP_USER" -
# -------------------------------------------------------
# 10. Permissions
# -------------------------------------------------------
echo "--- Setting ownership and permissions ---"
id -u "$LIQUIDSOAP_USER" &>/dev/null || useradd --system --shell /usr/sbin/nologin "$LIQUIDSOAP_USER"
chown -R "${LIQUIDSOAP_USER}:${LIQUIDSOAP_USER}" "$INSTALL_DIR"
chmod 700 "$INSTALL_DIR/state"
chmod 700 "$INSTALL_DIR/logs"
systemctl daemon-reload
# -------------------------------------------------------
# 11. Final verification
# -------------------------------------------------------
echo ""
echo "=== Final Verification ==="
ALL_OK=true
for dir in "${REQUIRED_DIRS[@]}"; do
if [ -d "$INSTALL_DIR/$dir" ] && [ -r "$INSTALL_DIR/$dir" ] && [ -w "$INSTALL_DIR/$dir" ]; then
printf " [OK] %-20s\n" "$dir/"
else
printf " [FAIL] %-20s (missing or inaccessible)\n" "$dir/"
ALL_OK=false
fi
done
if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then
echo " [OK] Service file: ${SERVICE_NAME}.service"
else
echo " [FAIL] Service file not created"
ALL_OK=false
fi
if [ -f "$CONFIG_FILE" ]; then
if jq empty "$CONFIG_FILE" 2>/dev/null; then
echo " [OK] Config: config.json (valid JSON)"
else
echo " [FAIL] Config: config.json (invalid JSON)"
ALL_OK=false
fi
else
echo " [FAIL] Config file missing"
ALL_OK=false
fi
if [ -f "$SCHEDULE_FILE" ]; then
echo " [OK] Schedule: schedule.txt"
else
echo " [FAIL] Schedule file missing"
ALL_OK=false
fi
echo ""
if [ "$ALL_OK" = true ]; then
echo "=== Installation Complete ==="
else
echo "=== Installation Finished With Errors ==="
echo "Review the [FAIL] items above before starting the service."
fi
echo ""
echo "Next steps:"
echo " 1. Place your music in $INSTALL_DIR/music/"
echo " 2. Edit $SCHEDULE_FILE with your show/stream schedule"
echo " 3. Review/edit $INSTALL_DIR/station.liq"
echo " 4. Test: sudo systemctl start icecast2 && sudo systemctl start ${SERVICE_NAME}"
echo " 5. Check logs: tail -f $INSTALL_DIR/logs/liquidsoap.log"
echo ""
echo "Manage shows:"
echo " List: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --list-shows --detail"
echo " Delete: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --delete-show <slug>"
echo " Import OPML: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --import-opml <file.opml>"
echo " Add show: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --add-show <feed-url>"
echo " JSON state: $PYTHON_BIN $INSTALL_DIR/update_playlists.py --json"

3
play Executable file
View file

@ -0,0 +1,3 @@
#!/bin/bash
source /etc/profile
liquidsoap 'output.icecast(%mp3, host = "192.168.0.200", port = 7777, user="source", password="listen", mount = "/audio", mksafe(playlist("/srv/audio/favorites.pls")))'

8
schedule.txt Normal file
View file

@ -0,0 +1,8 @@
# Radio Schedule
# Format: min hour dom mon dow TYPE TARGET [RUNLENGTH_SECONDS]
# TYPE: show | stream
# RUNLENGTH required for stream entries, ignored for show entries
#
# Examples:
# 0 8 * * 2 show hardcore_history
# 0 6 * * 1 stream http://example.org:8000/live.mp3 3600

116
station.liq Normal file
View file

@ -0,0 +1,116 @@
# station.liq - Main liquidsoap configuration
# All paths are resolved relative to this file's location.
# Move the whole directory tree and nothing else needs changing.
# -------------------------------------------------------
# Resolve root directory from script location
# -------------------------------------------------------
let ROOT = configure.bindir()
let MUSIC_DIR = ROOT ^ "/music"
let PODCASTS_DIR = ROOT ^ "/podcasts"
let PLAYLISTS_DIR = ROOT ^ "/playlists"
let SCHEDULE_FILE = ROOT ^ "/schedule.txt"
let CONFIG_FILE = ROOT ^ "/config.json"
# -------------------------------------------------------
# Load Icecast credentials from config.json
# -------------------------------------------------------
let cfg = json.from_file(CONFIG_FILE)
let ic_host = json.(cfg.icecast.host)
let ic_port = int_of_string(json.(cfg.icecast.port))
let ic_mount = json.(cfg.icecast.mount)
let ic_user = json.(cfg.icecast.username)
let ic_pass = json.(cfg.icecast.password)
# -------------------------------------------------------
# Background music source
# -------------------------------------------------------
let music =
playlist.recursive(
mode="random",
duration=3600,
path=MUSIC_DIR,
extensions=["mp3", "m4a", "ogg", "flac"]
)
# -------------------------------------------------------
# Scheduled content via request queue
# -------------------------------------------------------
def q = ref []
def push_request(req) =
q := req :: (!q)
# Build a show source from its .pls file (written by update_playlists.py)
def show_source(slug) =
let pls_path = PLAYLISTS_DIR ^ "/" ^ slug ^ ".pls"
if file.test(pls_path) then
log("# Playing scheduled show: #{slug}")
Some(request.create(pls_path))
else
log("! No playlist found for show: #{slug}, skipping")
None
end
# Parse schedule.txt and register cron-triggered tasks
def load_schedule() =
try
if not(file.test(SCHEDULE_FILE)) then
log("WARNING: schedule.txt not found at #{SCHEDULE_FILE}")
else
let lines = file.lines(SCHEDULE_FILE)
List.iter(fun line ->
let t = String.strip(line)
if t != "" && not(String.starts_with(t, "#")) then
let p = String.split(t, sep=" ")
|> List.filter(fun x -> String.strip(x) != "")
if List.length(p) >= 7 then
let cron_expr = String.concat(sep=" ", List.take(p, 5))
let stype = List.nth(p, 5)
let target = List.nth(p, 6)
match stype with
| "show" ->
cron.add(id=target, cron_expr, fun () ->
match show_source(target) with
| Some(r) -> push_request r
| None -> ()
)
| "stream" ->
let runlen_str =
if List.length(p) > 7 then List.nth(p, 7) else "3600"
let runlen = int_of_string(runlen_str)
cron.add(id=target, cron_expr, fun () ->
log("Triggering stream: #{target} (#{runlen}s)")
# For external streams, create a request with duration cap
let s = single.file(fallback=false, target)
push_request(request.create(target))
)
| _ ->
log("Unknown schedule type '#{stype}' for target '#{target}', ignoring")
end
else
log("Malformed schedule line (need at least 7 fields): #{t}")
end
end
) lines
log("Schedule loaded from #{SCHEDULE_FILE}")
with e ->
log("ERROR loading schedule: #{e}")
# Load schedule once at startup
thread.run(at=now(), fun () -> load_schedule())
# -------------------------------------------------------
# Output: scheduled content over background music fallback
# -------------------------------------------------------
output.icecast(
%mp3(bitrate=128),
host=ic_host,
port=ic_port,
user=ic_user,
password=ic_pass,
mount=ic_mount,
fallback=music,
request.queue(q)
)

26
update_playlists.py Executable file
View file

@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Regenerate per-show playlist files based on playback history."""
import os
import sys
import json
import sqlite3
from pathlib import Path
from datetime import datetime
AUDIO_ROOT = Path(__file__).resolve().parent
PLAYLISTS_DIR = AUDIO_ROOT / "playlists"
STATE_DB = AUDIO_ROOT / "state" / "played.db"
SUBS_DB = AUDIO_ROOT / "state" / "subscriptions.db"
SHOWS_DIR = AUDIO_ROOT / "podcasts"
def get_played_db():
STATE_DB.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(STATE_DB))
conn.execute("""CREATE TABLE IF NOT EXISTS played (
filename TEXT PRIMARY KEY,
show TEXT,
played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""")
return conn

138
update_playlists.rb Executable file
View file

@ -0,0 +1,138 @@
#!/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"
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 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
attr_reader :played_db, :subs_db
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 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]
)
played_db.commit
puts " #{slug}: queued #{File.basename(chosen)}"
true
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 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)
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 }
end
puts JSON.pretty_generate(out)
end
def close
played_db&.close
subs_db&.close
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