mirror of
https://github.com/mistergibson/radio.git
synced 2026-09-08 22:09:51 -07:00
Initial Commit
This commit is contained in:
parent
b91f497e49
commit
5a87c50f7d
10 changed files with 2710 additions and 0 deletions
439
LICENSE.txt
Normal file
439
LICENSE.txt
Normal 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 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.
|
||||||
459
fetch_podcasts.py
Executable file
459
fetch_podcasts.py
Executable 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
482
fetch_podcasts.rb
Executable 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
580
install_for_jruby
Executable 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
459
install_for_python
Executable 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
3
play
Executable 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
8
schedule.txt
Normal 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
116
station.liq
Normal 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
26
update_playlists.py
Executable 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
138
update_playlists.rb
Executable 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
|
||||||
Loading…
Reference in a new issue