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.
Files changed (64) hide show
  1. anchorless/__init__.py +8 -0
  2. anchorless/breaking.py +61 -0
  3. anchorless/cli.py +230 -0
  4. anchorless/config.example.toml +175 -0
  5. anchorless/config.py +573 -0
  6. anchorless/examples.py +33 -0
  7. anchorless/fonts/Barlow-Bold.ttf +0 -0
  8. anchorless/fonts/Barlow-Medium.ttf +0 -0
  9. anchorless/fonts/Barlow-Regular.ttf +0 -0
  10. anchorless/fonts/Barlow-SemiBold.ttf +0 -0
  11. anchorless/fonts/BarlowCondensed-Bold.ttf +0 -0
  12. anchorless/fonts/BarlowCondensed-ExtraBold.ttf +0 -0
  13. anchorless/fonts/BarlowCondensed-SemiBold.ttf +0 -0
  14. anchorless/fonts/OFL-Barlow.txt +93 -0
  15. anchorless/fonts/OFL-pixelifysans.txt +93 -0
  16. anchorless/fonts/OFL-pressstart2p.txt +93 -0
  17. anchorless/fonts/OFL-silkscreen.txt +93 -0
  18. anchorless/fonts/OFL-vt323.txt +93 -0
  19. anchorless/fonts/PixelifySans.ttf +0 -0
  20. anchorless/fonts/PressStart2P-Regular.ttf +0 -0
  21. anchorless/fonts/Silkscreen-Bold.ttf +0 -0
  22. anchorless/fonts/Silkscreen-Regular.ttf +0 -0
  23. anchorless/fonts/VT323-Regular.ttf +0 -0
  24. anchorless/headline.py +201 -0
  25. anchorless/local.example.toml +16 -0
  26. anchorless/models.py +95 -0
  27. anchorless/netguard.py +74 -0
  28. anchorless/newsroom.py +175 -0
  29. anchorless/passwords.py +52 -0
  30. anchorless/pipeline.py +159 -0
  31. anchorless/render/__init__.py +22 -0
  32. anchorless/render/crawl.py +87 -0
  33. anchorless/render/pages.py +364 -0
  34. anchorless/render/stills.py +66 -0
  35. anchorless/render/studio.py +754 -0
  36. anchorless/render/theme.py +180 -0
  37. anchorless/settings.py +629 -0
  38. anchorless/sound.py +241 -0
  39. anchorless/sources/__init__.py +0 -0
  40. anchorless/sources/rss.py +129 -0
  41. anchorless/store.py +99 -0
  42. anchorless/stream/__init__.py +0 -0
  43. anchorless/stream/admin.py +187 -0
  44. anchorless/stream/encoder.py +252 -0
  45. anchorless/stream/guide.py +99 -0
  46. anchorless/stream/playout.py +238 -0
  47. anchorless/stream/preview.py +49 -0
  48. anchorless/stream/server.py +556 -0
  49. anchorless/stream/tsbuffer.py +148 -0
  50. anchorless/stream/web.py +274 -0
  51. anchorless/text.py +107 -0
  52. anchorless/web/assets/settings.css +92 -0
  53. anchorless/web/assets/settings.js +501 -0
  54. anchorless/web/assets/style.css +179 -0
  55. anchorless/web/assets/viewer.js +305 -0
  56. anchorless/web/index.html +73 -0
  57. anchorless/web/settings.html +46 -0
  58. anchorless/web/vendor/mpegts.LICENSE +202 -0
  59. anchorless/web/vendor/mpegts.min.js +9 -0
  60. anchorless-1.0.0.dist-info/METADATA +136 -0
  61. anchorless-1.0.0.dist-info/RECORD +64 -0
  62. anchorless-1.0.0.dist-info/WHEEL +4 -0
  63. anchorless-1.0.0.dist-info/entry_points.txt +2 -0
  64. 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.