anchorless 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- anchorless/__init__.py +8 -0
- anchorless/breaking.py +61 -0
- anchorless/cli.py +230 -0
- anchorless/config.example.toml +175 -0
- anchorless/config.py +573 -0
- anchorless/examples.py +33 -0
- anchorless/fonts/Barlow-Bold.ttf +0 -0
- anchorless/fonts/Barlow-Medium.ttf +0 -0
- anchorless/fonts/Barlow-Regular.ttf +0 -0
- anchorless/fonts/Barlow-SemiBold.ttf +0 -0
- anchorless/fonts/BarlowCondensed-Bold.ttf +0 -0
- anchorless/fonts/BarlowCondensed-ExtraBold.ttf +0 -0
- anchorless/fonts/BarlowCondensed-SemiBold.ttf +0 -0
- anchorless/fonts/OFL-Barlow.txt +93 -0
- anchorless/fonts/OFL-pixelifysans.txt +93 -0
- anchorless/fonts/OFL-pressstart2p.txt +93 -0
- anchorless/fonts/OFL-silkscreen.txt +93 -0
- anchorless/fonts/OFL-vt323.txt +93 -0
- anchorless/fonts/PixelifySans.ttf +0 -0
- anchorless/fonts/PressStart2P-Regular.ttf +0 -0
- anchorless/fonts/Silkscreen-Bold.ttf +0 -0
- anchorless/fonts/Silkscreen-Regular.ttf +0 -0
- anchorless/fonts/VT323-Regular.ttf +0 -0
- anchorless/headline.py +201 -0
- anchorless/local.example.toml +16 -0
- anchorless/models.py +95 -0
- anchorless/netguard.py +74 -0
- anchorless/newsroom.py +175 -0
- anchorless/passwords.py +52 -0
- anchorless/pipeline.py +159 -0
- anchorless/render/__init__.py +22 -0
- anchorless/render/crawl.py +87 -0
- anchorless/render/pages.py +364 -0
- anchorless/render/stills.py +66 -0
- anchorless/render/studio.py +754 -0
- anchorless/render/theme.py +180 -0
- anchorless/settings.py +629 -0
- anchorless/sound.py +241 -0
- anchorless/sources/__init__.py +0 -0
- anchorless/sources/rss.py +129 -0
- anchorless/store.py +99 -0
- anchorless/stream/__init__.py +0 -0
- anchorless/stream/admin.py +187 -0
- anchorless/stream/encoder.py +252 -0
- anchorless/stream/guide.py +99 -0
- anchorless/stream/playout.py +238 -0
- anchorless/stream/preview.py +49 -0
- anchorless/stream/server.py +556 -0
- anchorless/stream/tsbuffer.py +148 -0
- anchorless/stream/web.py +274 -0
- anchorless/text.py +107 -0
- anchorless/web/assets/settings.css +92 -0
- anchorless/web/assets/settings.js +501 -0
- anchorless/web/assets/style.css +179 -0
- anchorless/web/assets/viewer.js +305 -0
- anchorless/web/index.html +73 -0
- anchorless/web/settings.html +46 -0
- anchorless/web/vendor/mpegts.LICENSE +202 -0
- anchorless/web/vendor/mpegts.min.js +9 -0
- anchorless-1.0.0.dist-info/METADATA +136 -0
- anchorless-1.0.0.dist-info/RECORD +64 -0
- anchorless-1.0.0.dist-info/WHEEL +4 -0
- anchorless-1.0.0.dist-info/entry_points.txt +2 -0
- anchorless-1.0.0.dist-info/licenses/LICENSE +21 -0
anchorless/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Anchorless: a 24/7 text newscast for IPTV."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("anchorless") # one source of truth: pyproject.toml
|
|
7
|
+
except PackageNotFoundError: # running from a source tree that isn't installed
|
|
8
|
+
__version__ = "0+unknown"
|
anchorless/breaking.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Deciding when a story is big and new enough to interrupt the rundown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
from .config import BreakingConfig
|
|
9
|
+
from .models import Cluster
|
|
10
|
+
from .store import Store
|
|
11
|
+
from .text import similarity
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
_logged: set[str] = set() # local stories already logged in "log" mode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _prefixed(cluster: Cluster, prefixes: list[str]) -> bool:
|
|
18
|
+
return any((s.raw_title or s.title).upper().startswith(p.upper()) for s in cluster.stories for p in prefixes)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _already_alerted(cluster: Cluster, alerts: list[tuple[str, set[str], datetime]]) -> bool:
|
|
22
|
+
keys = {s.key for s in cluster.stories} | {cluster.key}
|
|
23
|
+
return any(k in keys or similarity(cluster.tokens, tokens) >= 0.5 for k, tokens, _ in alerts)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pick_breaking(clusters: list[Cluster], cfg: BreakingConfig, store: Store, now: datetime) -> Cluster | None:
|
|
27
|
+
"""The story to break in with now, if any (and record it so it is never repeated).
|
|
28
|
+
|
|
29
|
+
A story qualifies when it appeared within ``window_minutes`` and either at
|
|
30
|
+
least ``min_sources`` outlets already carry it, or a headline is marked
|
|
31
|
+
"BREAKING:". At most one alert per ``cooldown_minutes``. A story only local
|
|
32
|
+
outlets carry needs ``local_min_sources`` of them instead.
|
|
33
|
+
"""
|
|
34
|
+
if not cfg.enabled:
|
|
35
|
+
return None
|
|
36
|
+
last = store.last_alert_at()
|
|
37
|
+
if last and now - last < timedelta(minutes=cfg.cooldown_minutes):
|
|
38
|
+
return None
|
|
39
|
+
window = timedelta(minutes=cfg.window_minutes)
|
|
40
|
+
alerts = store.alerts()
|
|
41
|
+
for cluster in clusters: # best first
|
|
42
|
+
when = cluster.when
|
|
43
|
+
if when is None or now - when > window:
|
|
44
|
+
continue
|
|
45
|
+
local = all(s.scope == "local" for s in cluster.stories)
|
|
46
|
+
if local and cfg.local_alerts == "off":
|
|
47
|
+
continue
|
|
48
|
+
need = cfg.local_min_sources if local else cfg.min_sources
|
|
49
|
+
if len(cluster.outlets) < need and not _prefixed(cluster, cfg.title_prefixes):
|
|
50
|
+
continue
|
|
51
|
+
if _already_alerted(cluster, alerts):
|
|
52
|
+
continue
|
|
53
|
+
if local and cfg.local_alerts == "log":
|
|
54
|
+
if cluster.key not in _logged:
|
|
55
|
+
_logged.add(cluster.key)
|
|
56
|
+
log.info("local story would break in (local_alerts = log): %s (%s)",
|
|
57
|
+
cluster.lead.title, ", ".join(cluster.outlets))
|
|
58
|
+
continue
|
|
59
|
+
store.record_alert(cluster.key, cluster.lead.title, cluster.tokens, now)
|
|
60
|
+
return cluster
|
|
61
|
+
return None
|
anchorless/cli.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Command line: anchorless init | serve | fetch | render | preview | ..."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .config import Config, ConfigError, config_path, load_config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _age(when: datetime | None, now: datetime) -> str:
|
|
19
|
+
if when is None:
|
|
20
|
+
return " ? "
|
|
21
|
+
minutes = int((now - when).total_seconds() // 60)
|
|
22
|
+
return f"{minutes:3d}m" if minutes < 120 else f"{minutes // 60:3d}h"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _fetch(cfg: Config, as_json: bool) -> int:
|
|
26
|
+
from .newsroom import Newsroom
|
|
27
|
+
|
|
28
|
+
room = Newsroom(cfg)
|
|
29
|
+
rundown = await room.refresh()
|
|
30
|
+
now = rundown.built_at
|
|
31
|
+
if as_json:
|
|
32
|
+
print(json.dumps({
|
|
33
|
+
"built_at": now.isoformat(),
|
|
34
|
+
"segments": [{
|
|
35
|
+
"name": seg.name,
|
|
36
|
+
"stories": [{
|
|
37
|
+
"title": c.lead.title, "summary": c.lead.summary, "outlets": c.outlets,
|
|
38
|
+
"url": c.lead.url, "when": c.when.isoformat() if c.when else None,
|
|
39
|
+
"score": round(c.score, 3),
|
|
40
|
+
} for c in seg.clusters],
|
|
41
|
+
} for seg in rundown.segments],
|
|
42
|
+
"feeds": room.feed_status(),
|
|
43
|
+
}, indent=2))
|
|
44
|
+
return 0
|
|
45
|
+
for seg in rundown.segments:
|
|
46
|
+
print(f"\n== {seg.name}")
|
|
47
|
+
for c in seg.clusters:
|
|
48
|
+
print(f" {_age(c.when, now)} [{len(c.outlets)}] {c.lead.title}")
|
|
49
|
+
print(f" {', '.join(c.outlets)}")
|
|
50
|
+
failed = [f for f in room.feed_status() if f["error"]]
|
|
51
|
+
print(f"\n{sum(f['stories'] for f in room.feed_status())} stories from {len(cfg.source)} feeds", end="")
|
|
52
|
+
print(f", {len(failed)} failed:" if failed else "")
|
|
53
|
+
for f in failed:
|
|
54
|
+
print(f" {f['name']} {f['url']}: {f['error']}")
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _headlines(cfg: Config, dump: str | None) -> int:
|
|
59
|
+
"""Every story several outlets carry: each version, its score and why."""
|
|
60
|
+
from datetime import UTC, datetime
|
|
61
|
+
|
|
62
|
+
from . import headline
|
|
63
|
+
from .pipeline import all_clusters
|
|
64
|
+
from .sources.rss import RssFetcher
|
|
65
|
+
|
|
66
|
+
now = datetime.now(UTC)
|
|
67
|
+
stories = await RssFetcher().fetch_all(cfg.source, cfg.feeds.allow_private)
|
|
68
|
+
clusters = all_clusters(stories, cfg, now)
|
|
69
|
+
rows = []
|
|
70
|
+
for c in clusters:
|
|
71
|
+
choice = headline.choose(c, cfg.rundown.headline_margin)
|
|
72
|
+
if choice is None:
|
|
73
|
+
continue
|
|
74
|
+
local = any(s.scope == "local" for s in c.stories)
|
|
75
|
+
print(f"\n[{len(c.outlets)} outlets] {'CHANGED' if choice.changed else 'kept'}"
|
|
76
|
+
+ (f" - {choice.reason}" if choice.changed else ""))
|
|
77
|
+
for x in sorted(choice.scores, key=lambda x: -x.total):
|
|
78
|
+
mark = ">" if x.story is choice.chosen else ("d" if x.story is choice.default else " ")
|
|
79
|
+
print(f" {mark} {x.total:5.2f} {x.story.outlet[:18]:18} {x.story.title[:90]}")
|
|
80
|
+
print(f" {x.why()}")
|
|
81
|
+
if dump and not local: # local stories would say where you live
|
|
82
|
+
rows.append({"when": now.isoformat(timespec="seconds"), "key": c.key, "changed": choice.changed,
|
|
83
|
+
"chosen": choice.chosen.key, "default": choice.default.key, "versions": [
|
|
84
|
+
{"key": x.story.key, "outlet": x.story.outlet, "title": x.story.title,
|
|
85
|
+
"summary": x.story.summary, "scope": x.story.scope, "total": round(x.total, 3),
|
|
86
|
+
"corroboration": round(x.corroboration, 3), "coverage": round(x.coverage, 3),
|
|
87
|
+
"penalties": x.penalties} for x in choice.scores]})
|
|
88
|
+
if dump:
|
|
89
|
+
with open(dump, "a", encoding="utf-8") as f:
|
|
90
|
+
for r in rows:
|
|
91
|
+
f.write(json.dumps(r) + "\n")
|
|
92
|
+
print(f"\n{len(rows)} stories appended to {dump}", file=sys.stderr)
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _hash_password(from_stdin: bool) -> int:
|
|
97
|
+
import getpass
|
|
98
|
+
|
|
99
|
+
from .passwords import MIN_LENGTH, hash_password
|
|
100
|
+
if from_stdin:
|
|
101
|
+
password = sys.stdin.readline().rstrip("\n")
|
|
102
|
+
else:
|
|
103
|
+
password = getpass.getpass(f"Settings password (at least {MIN_LENGTH} characters): ")
|
|
104
|
+
if getpass.getpass("Again: ") != password:
|
|
105
|
+
print("The two passwords differ.", file=sys.stderr)
|
|
106
|
+
return 2
|
|
107
|
+
try:
|
|
108
|
+
hashed = hash_password(password)
|
|
109
|
+
except ValueError as err:
|
|
110
|
+
print(err, file=sys.stderr)
|
|
111
|
+
return 2
|
|
112
|
+
print("Put this in config.toml (and remove any [admin] password / ANCHORLESS__ADMIN__PASSWORD):\n",
|
|
113
|
+
file=sys.stderr)
|
|
114
|
+
print(f'[admin]\npassword_hash = "{hashed}"')
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _init(folder: Path, force: bool) -> int:
|
|
119
|
+
from .examples import init
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
written = init(folder, force)
|
|
123
|
+
except FileExistsError as err:
|
|
124
|
+
print(err, file=sys.stderr)
|
|
125
|
+
return 1
|
|
126
|
+
for path in written:
|
|
127
|
+
print(f"wrote {path}")
|
|
128
|
+
here = "" if folder.resolve() == Path.cwd() else f"cd {folder} && "
|
|
129
|
+
print(f"""
|
|
130
|
+
Next:
|
|
131
|
+
1. Look through config.toml: every setting is explained there. The defaults work.
|
|
132
|
+
Local news: set ANCHORLESS__LOCATION__CITY and list your outlets' feeds
|
|
133
|
+
(see local.example.toml).
|
|
134
|
+
2. Start it: {here}anchorless serve
|
|
135
|
+
3. Watch at http://localhost:9798/ or add http://<this host>:9798/playlist.m3u
|
|
136
|
+
to Jellyfin, Plex or any IPTV player as an M3U tuner.
|
|
137
|
+
|
|
138
|
+
Needs ffmpeg on the PATH.""")
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main(argv: list[str] | None = None) -> int:
|
|
143
|
+
parser = argparse.ArgumentParser(prog="anchorless", description="A 24/7 text newscast for IPTV.")
|
|
144
|
+
parser.add_argument("--version", action="version", version=f"anchorless {__version__}")
|
|
145
|
+
parser.add_argument("-c", "--config", help="config file (default: $ANCHORLESS_CONFIG or ./config.toml)")
|
|
146
|
+
parser.add_argument("-v", "--verbose", action="store_true")
|
|
147
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
148
|
+
|
|
149
|
+
p = sub.add_parser("fetch", help="fetch the sources once and print the rundown")
|
|
150
|
+
p.add_argument("--json", action="store_true")
|
|
151
|
+
|
|
152
|
+
p = sub.add_parser("render", help="write PNG stills of each page type")
|
|
153
|
+
p.add_argument("-o", "--out", default="out", help="output directory (default: out)")
|
|
154
|
+
p.add_argument("-t", "--theme", choices=["studio", "classic", "classic-light", "classic-dark"], help="default: the configured theme")
|
|
155
|
+
|
|
156
|
+
p = sub.add_parser("preview", help="write a short MP4 of the channel")
|
|
157
|
+
p.add_argument("-o", "--out", default="out/preview.mp4")
|
|
158
|
+
p.add_argument("-s", "--seconds", type=float, default=60)
|
|
159
|
+
p.add_argument("--breaking", action="store_true", help="start with a (sample) breaking-news alert")
|
|
160
|
+
p.add_argument("-t", "--theme", choices=["studio", "classic", "classic-light", "classic-dark"], help="default: the configured theme")
|
|
161
|
+
|
|
162
|
+
p = sub.add_parser("init", help="write a starter config.toml (and local.example.toml) to get going")
|
|
163
|
+
p.add_argument("folder", nargs="?", default=".", help="where to write it (default: here)")
|
|
164
|
+
p.add_argument("--force", action="store_true", help="replace an existing config.toml")
|
|
165
|
+
p = sub.add_parser("serve", help="run the channel: /playlist.m3u, /guide.xml, /live.ts, /health")
|
|
166
|
+
p.add_argument("--init", action="store_true",
|
|
167
|
+
help="first write a starter config if there is none (the container image does this)")
|
|
168
|
+
sub.add_parser("check-config", help="validate the config and print it")
|
|
169
|
+
p = sub.add_parser("headlines", help="each version of every multi-outlet story, scored (consensus headline)")
|
|
170
|
+
p.add_argument("--dump", help="append the stories (not local ones) to this JSONL file, for calibration")
|
|
171
|
+
p = sub.add_parser("hash-password", help="make a password_hash for [admin] (the settings page)")
|
|
172
|
+
p.add_argument("--stdin", action="store_true", help="read the password from standard input")
|
|
173
|
+
|
|
174
|
+
args = parser.parse_args(argv)
|
|
175
|
+
logging.basicConfig(
|
|
176
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
177
|
+
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", stream=sys.stderr,
|
|
178
|
+
)
|
|
179
|
+
if not args.verbose:
|
|
180
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
181
|
+
if args.command == "hash-password": # needs no config: you run it while writing one
|
|
182
|
+
return _hash_password(args.stdin)
|
|
183
|
+
if args.command == "init":
|
|
184
|
+
return _init(Path(args.folder), args.force)
|
|
185
|
+
if args.command == "serve" and args.init:
|
|
186
|
+
target = Path(args.config or os.environ.get("ANCHORLESS_CONFIG") or "config.toml")
|
|
187
|
+
if not target.exists():
|
|
188
|
+
from .examples import init
|
|
189
|
+
|
|
190
|
+
init(target.parent, filename=target.name)
|
|
191
|
+
logging.getLogger("anchorless").info("wrote a starter config to %s", target)
|
|
192
|
+
args.config = str(target)
|
|
193
|
+
try:
|
|
194
|
+
cfg = load_config(args.config)
|
|
195
|
+
cfg_path = Path(args.config) if args.config else config_path()
|
|
196
|
+
except ConfigError as err:
|
|
197
|
+
print(err, file=sys.stderr)
|
|
198
|
+
return 2
|
|
199
|
+
|
|
200
|
+
if args.command == "check-config":
|
|
201
|
+
shown = cfg.model_copy(deep=True)
|
|
202
|
+
if shown.admin.password:
|
|
203
|
+
shown.admin.password = "<set>" # never print the settings password
|
|
204
|
+
if shown.admin.password_hash:
|
|
205
|
+
shown.admin.password_hash = "<set>"
|
|
206
|
+
print(shown.model_dump_json(indent=2))
|
|
207
|
+
return 0
|
|
208
|
+
if args.command == "headlines":
|
|
209
|
+
return asyncio.run(_headlines(cfg, args.dump))
|
|
210
|
+
if args.command == "fetch":
|
|
211
|
+
return asyncio.run(_fetch(cfg, args.json))
|
|
212
|
+
if args.command == "render":
|
|
213
|
+
from .render.stills import write_stills
|
|
214
|
+
|
|
215
|
+
return asyncio.run(write_stills(cfg, Path(args.out), args.theme))
|
|
216
|
+
if args.command == "preview":
|
|
217
|
+
from .stream.preview import write_preview
|
|
218
|
+
|
|
219
|
+
if args.theme:
|
|
220
|
+
cfg.channel.theme = args.theme
|
|
221
|
+
return asyncio.run(write_preview(cfg, Path(args.out), args.seconds, args.breaking))
|
|
222
|
+
if args.command == "serve":
|
|
223
|
+
from .stream.server import serve
|
|
224
|
+
|
|
225
|
+
return serve(cfg, cfg_path)
|
|
226
|
+
return 1
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
if __name__ == "__main__":
|
|
230
|
+
sys.exit(main())
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Anchorless configuration.
|
|
2
|
+
#
|
|
3
|
+
# Copy this to config.toml and change what you like. In [channel], theme and
|
|
4
|
+
# show_on are required (and channel_number for a TV channel); every other
|
|
5
|
+
# setting is optional, and anything left out uses the default shown here.
|
|
6
|
+
#
|
|
7
|
+
# Any setting can also come from the environment as ANCHORLESS__<SECTION>__<KEY>,
|
|
8
|
+
# e.g. ANCHORLESS__LOCATION__CITY=Springfield. Use that for anything personal
|
|
9
|
+
# (like your location) so this file can be shared.
|
|
10
|
+
|
|
11
|
+
[channel]
|
|
12
|
+
theme = "studio" # required: "studio" (pixel-art news set), "classic-light" / "classic-dark"
|
|
13
|
+
# (cable-news pages), or "classic" (light by day, dark at night)
|
|
14
|
+
name = "NEWS" # shown in the logo and in your TV guide (default "NEWS")
|
|
15
|
+
show_on = "both" # required: "both" (TV + web page), "tv" (TV only) or "web" (web page only)
|
|
16
|
+
channel_number = 101 # required for "both" and "tv" (e.g. 2, 101, 2.1); ignored for "web"
|
|
17
|
+
# id = "anchorless.news" # stable id for TV guides (tvg-id) and the stream URL
|
|
18
|
+
timezone = "America/Chicago" # for the on-screen clock and the guide
|
|
19
|
+
guide_title = "Anchorless News"
|
|
20
|
+
guide_description = "Around-the-clock headlines from national and world news outlets."
|
|
21
|
+
guide_slot_minutes = 30 # guide blocks on :00/:30; 1440 = one per day
|
|
22
|
+
|
|
23
|
+
[location]
|
|
24
|
+
# Local news. Best set from the environment instead (ANCHORLESS__LOCATION__CITY=...),
|
|
25
|
+
# so this file names no place:
|
|
26
|
+
# city = "Springfield" # the local segment's title
|
|
27
|
+
# sources_file = "local.toml" # your local outlets' feeds (see local.example.toml),
|
|
28
|
+
# # relative to this file. No file = no local segment.
|
|
29
|
+
|
|
30
|
+
[video]
|
|
31
|
+
width = 854
|
|
32
|
+
height = 480
|
|
33
|
+
fps = 24
|
|
34
|
+
encoder = "x264" # "x264" (CPU) or "vaapi" (Intel/AMD GPU)
|
|
35
|
+
vaapi_device = "/dev/dri/renderD128"
|
|
36
|
+
vaapi_rc = "vbr" # "cqp" for older Intel GPUs (i965 driver) that reject bitrates
|
|
37
|
+
vaapi_qp = 24 # quality for "cqp" (lower = better)
|
|
38
|
+
vaapi_convert = "cpu" # "gpu" converts colours on the GPU too (less CPU; newer Intel drivers)
|
|
39
|
+
bitrate = "1500k"
|
|
40
|
+
maxrate = "2000k"
|
|
41
|
+
gop_seconds = 2 # a keyframe every 2 s: how fast a joining viewer can start
|
|
42
|
+
x264_preset = "veryfast"
|
|
43
|
+
x264_threads = 0 # 0 = automatic
|
|
44
|
+
|
|
45
|
+
[audio]
|
|
46
|
+
music_dir = "/music" # background music (.mp3), shuffled; missing folder = silence
|
|
47
|
+
volume = 0.5
|
|
48
|
+
bitrate = "128k"
|
|
49
|
+
|
|
50
|
+
[stream]
|
|
51
|
+
host = "0.0.0.0"
|
|
52
|
+
port = 9798
|
|
53
|
+
lead_seconds = 10 # rendered ahead for a new viewer (Samsung TVs buffer ~10 s)
|
|
54
|
+
idle_seconds = 60 # stop encoding this long after the last viewer leaves
|
|
55
|
+
max_sessions = 2 # channels encoding at once (~30% of a CPU core each); -1 = no limit,
|
|
56
|
+
# 0 = listed but nobody can watch. Viewers of a channel already on don't count.
|
|
57
|
+
max_viewers = -1 # viewers at once, all channels (TV, web, iPhone); -1 = no limit, 0 = nobody
|
|
58
|
+
max_viewers_per_client = -1 # viewers from one address; -1 = no limit. Jellyfin (all its viewers
|
|
59
|
+
# come from it) and a household behind one router are one address each.
|
|
60
|
+
|
|
61
|
+
[web] # the viewer page; the TV side (playlist, guide, streams) is never https-only
|
|
62
|
+
require_https = false # true: viewer page only over https. Suggested if you have https - and
|
|
63
|
+
# keep [admin] require_https on too.
|
|
64
|
+
public_status = "full" # "full": /health and the viewer page list feeds by name and site;
|
|
65
|
+
# "summary": counts only. URLs and error details: settings page only.
|
|
66
|
+
|
|
67
|
+
[rundown]
|
|
68
|
+
refresh_minutes = 10 # how often the sources are fetched
|
|
69
|
+
page_seconds = 12 # how long each story stays on screen
|
|
70
|
+
intro_seconds = 5 # segment title card (0 = none)
|
|
71
|
+
transition_seconds = 0.4 # crossfade between pages (0 = cut)
|
|
72
|
+
max_story_age_hours = 12
|
|
73
|
+
same_story_similarity = 0.5 # headline overlap that makes two items the same story
|
|
74
|
+
same_story_text_similarity = 0.32
|
|
75
|
+
|
|
76
|
+
# The newscast: segments play in order, then it loops. A story appears in the
|
|
77
|
+
# first segment it fits and nowhere else. Leave out scope/category to take any story.
|
|
78
|
+
# When several outlets carry a story, whose headline is shown: "first" (first
|
|
79
|
+
# published), "consensus" (if that one has a red flag - loaded words, shouting, or
|
|
80
|
+
# wording no other outlet shares - a clean version the others agree with), or "log"
|
|
81
|
+
# (show "first"; list what consensus would change, on the settings page and in the log).
|
|
82
|
+
headline_choice = "log"
|
|
83
|
+
headline_margin = 0.15 # how much better a version must score to replace the shown one
|
|
84
|
+
|
|
85
|
+
[[rundown.segment]]
|
|
86
|
+
name = "TOP STORIES"
|
|
87
|
+
stories = 4
|
|
88
|
+
exclude_scope = ["local"] # local-only stories stay in the local segment
|
|
89
|
+
|
|
90
|
+
[[rundown.segment]]
|
|
91
|
+
name = "{city}" # the local segment, titled with [location] city
|
|
92
|
+
stories = 3
|
|
93
|
+
scope = "local"
|
|
94
|
+
|
|
95
|
+
[[rundown.segment]]
|
|
96
|
+
name = "NATIONAL"
|
|
97
|
+
stories = 3
|
|
98
|
+
scope = "national" # local | national | world
|
|
99
|
+
|
|
100
|
+
[[rundown.segment]]
|
|
101
|
+
name = "WORLD"
|
|
102
|
+
stories = 3
|
|
103
|
+
scope = "world"
|
|
104
|
+
|
|
105
|
+
[[rundown.segment]]
|
|
106
|
+
name = "BUSINESS"
|
|
107
|
+
stories = 2
|
|
108
|
+
category = "business"
|
|
109
|
+
|
|
110
|
+
[breaking]
|
|
111
|
+
enabled = true
|
|
112
|
+
min_sources = 4 # outlets that must carry a story...
|
|
113
|
+
window_minutes = 30 # ...within this long of its first appearance
|
|
114
|
+
cooldown_minutes = 20 # at most one alert per this long
|
|
115
|
+
crawl_tag_minutes = 60 # how long it keeps the red BREAKING tag in the crawl
|
|
116
|
+
title_prefixes = ["BREAKING:", "BREAKING NEWS:"]
|
|
117
|
+
local_min_sources = 5 # a story only local outlets carry needs this many
|
|
118
|
+
local_alerts = "on" # "on", "log" (only log what would break in), or "off"
|
|
119
|
+
sound = "auto" # "auto", "chiptune", "news", "none", or a sound file's path
|
|
120
|
+
sound_volume = 0.8 # the music ducks under it either way
|
|
121
|
+
|
|
122
|
+
[crawl] # the headline crawl along the bottom, on every channel
|
|
123
|
+
label = "HEADLINES"
|
|
124
|
+
speed = 96 # pixels per second at 480 lines
|
|
125
|
+
|
|
126
|
+
# More channels from the same news (one fetch feeds them all). The settings are
|
|
127
|
+
# the same as [channel], except that name is required here. Each channel gets its
|
|
128
|
+
# own stream, drawn and encoded only while someone watches it. Add as many as you like.
|
|
129
|
+
#
|
|
130
|
+
# [[extra_channel]]
|
|
131
|
+
# theme = "classic"
|
|
132
|
+
# name = "NEWS CLASSIC" # required for an extra channel
|
|
133
|
+
# show_on = "web"
|
|
134
|
+
# channel_number = 99 # ignored: show_on is "web"
|
|
135
|
+
#
|
|
136
|
+
# [[extra_channel]]
|
|
137
|
+
# theme = "studio"
|
|
138
|
+
# name = "NEWS 2"
|
|
139
|
+
# show_on = "tv"
|
|
140
|
+
# channel_number = 102
|
|
141
|
+
|
|
142
|
+
[data]
|
|
143
|
+
dir = "data" # SQLite memory of stories already seen
|
|
144
|
+
|
|
145
|
+
# Feeds. Anchorless comes with a list of national and world outlets (NPR, NYT,
|
|
146
|
+
# CBS, ABC, NBC, Fox, Guardian, BBC, Al Jazeera, CNBC). [[source]] entries are
|
|
147
|
+
# added to that list; turn any feed off by its URL. Feeds with the same name
|
|
148
|
+
# count as one outlet. scope: national | world (local feeds go in the local
|
|
149
|
+
# feeds file, see [location]). exclude: regular expressions; matching
|
|
150
|
+
# headlines or links are skipped.
|
|
151
|
+
[feeds]
|
|
152
|
+
builtin = true # false: only your [[source]] entries
|
|
153
|
+
disabled = [] # feed URLs to skip, e.g. ["https://feeds.npr.org/1001/rss.xml"]
|
|
154
|
+
allow_private = false # true: allow feeds on your own network. Off, every feed request
|
|
155
|
+
# (redirects too) must go to a public address - so a URL typed
|
|
156
|
+
# into the settings page can't point the server at your router etc.
|
|
157
|
+
|
|
158
|
+
# [[source]]
|
|
159
|
+
# name = "Reuters"
|
|
160
|
+
# url = "https://example.com/reuters/rss.xml"
|
|
161
|
+
# scope = "world"
|
|
162
|
+
# category = "general" # "business" feeds the BUSINESS segment
|
|
163
|
+
|
|
164
|
+
# The settings page (/settings) edits this file. It is off until a password is
|
|
165
|
+
# set (12+ characters): either from the environment, ANCHORLESS__ADMIN__PASSWORD=...,
|
|
166
|
+
# or as a hash here - run `anchorless hash-password` and paste its line:
|
|
167
|
+
# password_hash = "scrypt$15$8$1$...$..."
|
|
168
|
+
# The file must be writable by Anchorless (in Docker: mount its folder, not the file).
|
|
169
|
+
[admin]
|
|
170
|
+
allow_from = ["127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
|
171
|
+
"100.64.0.0/10", "fc00::/7", "fe80::/10"] # who may open the settings page
|
|
172
|
+
trusted_proxies = [] # e.g. ["192.168.1.2"]: your reverse proxy, so X-Forwarded-For is believed
|
|
173
|
+
session_days = 30 # how long a login lasts
|
|
174
|
+
require_https = true # log in only over https (TLS, or a trusted proxy's X-Forwarded-Proto);
|
|
175
|
+
# localhost is always allowed. false only on a network you trust.
|