diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..eb5ca31 --- /dev/null +++ b/LICENSE.txt @@ -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 Licensee’s 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 Licensee’s 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 person’s 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 person’s + 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 Network’s 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 Licensee’s 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 Licensee’s website and/or, to the extent + Licensee is a representative, agent, affiliate, successor, attorney, + subsidiary, or assign, on Licensee’s principal’s or parent’s 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 Software’s 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 LICENSEE’S +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, Licensee’s +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 Software’s 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 Licensor’s and Licensee’s respective heirs, successors, and +assigns. \ No newline at end of file diff --git a/fetch_podcasts.py b/fetch_podcasts.py new file mode 100755 index 0000000..bbb5ddb --- /dev/null +++ b/fetch_podcasts.py @@ -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 ") + 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 ") + 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 ") + 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() diff --git a/fetch_podcasts.rb b/fetch_podcasts.rb new file mode 100755 index 0000000..4732b6b --- /dev/null +++ b/fetch_podcasts.rb @@ -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 () and Atom () + 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 " + 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 " + 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 " + 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 diff --git a/install_for_jruby b/install_for_jruby new file mode 100755 index 0000000..d73b88d --- /dev/null +++ b/install_for_jruby @@ -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 " +echo " Import OPML: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --import-opml " +echo " Add show: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --add-show " +echo " JSON state: $RB_PREFIX $INSTALL_DIR/update_playlists.rb --json" diff --git a/install_for_python b/install_for_python new file mode 100755 index 0000000..5a9f50f --- /dev/null +++ b/install_for_python @@ -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 " +echo " Import OPML: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --import-opml " +echo " Add show: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --add-show " +echo " JSON state: $PYTHON_BIN $INSTALL_DIR/update_playlists.py --json" diff --git a/play b/play new file mode 100755 index 0000000..633bd22 --- /dev/null +++ b/play @@ -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")))' diff --git a/schedule.txt b/schedule.txt new file mode 100644 index 0000000..3ed5d7e --- /dev/null +++ b/schedule.txt @@ -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 diff --git a/station.liq b/station.liq new file mode 100644 index 0000000..a8ba2ac --- /dev/null +++ b/station.liq @@ -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) +) diff --git a/update_playlists.py b/update_playlists.py new file mode 100755 index 0000000..48f5ae6 --- /dev/null +++ b/update_playlists.py @@ -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 diff --git a/update_playlists.rb b/update_playlists.rb new file mode 100755 index 0000000..7e53383 --- /dev/null +++ b/update_playlists.rb @@ -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