streamlens 0.1.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.
streamlens/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """streamlens: live streams in one auto-updating Telegram message."""
2
+ from .message import build_rich_message, build_text_message
3
+ from .models import Stream, detect_platform
4
+ from .publisher import Publisher, publish
5
+ from .telegram import TelegramClient, TelegramError
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = [
10
+ "Stream", "Publisher", "publish", "detect_platform", "build_rich_message", "build_text_message",
11
+ "TelegramClient", "TelegramError", "__version__",
12
+ ]
streamlens/cli.py ADDED
@@ -0,0 +1,193 @@
1
+ """Command line: ``streamlens send | watch | check | clear``."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import csv
6
+ import datetime as dt
7
+ import io
8
+ import json
9
+ import os
10
+ import sys
11
+ import time
12
+ from typing import List, Optional
13
+ from urllib.request import urlopen
14
+
15
+ from . import __version__
16
+ from .models import Stream
17
+ from .publisher import Publisher
18
+ from .sources import MissingDependency, TwitchApi, parse_channels, probe_channels
19
+ from .telegram import TelegramClient, TelegramError
20
+
21
+ ENV_TOKEN = ("STREAMLENS_TOKEN", "TELEGRAM_BOT_TOKEN")
22
+ ENV_CHAT = ("STREAMLENS_CHAT_ID", "TELEGRAM_CHAT_ID")
23
+
24
+
25
+ def _env(names) -> Optional[str]:
26
+ return next((os.environ[n] for n in names if os.environ.get(n)), None)
27
+
28
+
29
+ def _load_image(value: Optional[str], base_dir: str) -> Optional[bytes]:
30
+ if not value:
31
+ return None
32
+ if value.startswith(("http://", "https://")):
33
+ with urlopen(value, timeout=20) as r: # noqa: S310 (a URL that the user gave)
34
+ return r.read()
35
+ path = value if os.path.isabs(value) else os.path.join(base_dir, value)
36
+ with open(path, "rb") as f:
37
+ return f.read()
38
+
39
+
40
+ def _num(value) -> Optional[int]:
41
+ return None if value in (None, "") else int(float(value))
42
+
43
+
44
+ def load_streams(path: str) -> List[Stream]:
45
+ """Reads a JSON list or a CSV (header: name,url,online,...) from a file or stdin (``-``).
46
+
47
+ Columns / keys: name, url, online, platform, title, started_at (ISO 8601), watch_url, online_diff,
48
+ image (a file path or an http(s) URL)."""
49
+ text = sys.stdin.read() if path == "-" else open(path, encoding="utf-8").read()
50
+ base_dir = "." if path == "-" else os.path.dirname(os.path.abspath(path))
51
+ if text.lstrip().startswith(("[", "{")):
52
+ data = json.loads(text)
53
+ rows = data["streams"] if isinstance(data, dict) else data
54
+ else:
55
+ rows = list(csv.DictReader(io.StringIO(text)))
56
+
57
+ streams = []
58
+ for row in rows:
59
+ started = row.get("started_at")
60
+ streams.append(Stream(
61
+ name=row["name"], url=row["url"], online=_num(row.get("online")), platform=row.get("platform") or None,
62
+ online_diff=_num(row.get("online_diff")), title=row.get("title") or None,
63
+ watch_url=row.get("watch_url") or None,
64
+ started_at=dt.datetime.fromisoformat(started.replace("Z", "+00:00")) if started else None,
65
+ image=_load_image(row.get("image"), base_dir)))
66
+ return streams
67
+
68
+
69
+ def _publisher(args) -> Publisher:
70
+ token, chat_id = args.token or _env(ENV_TOKEN), args.chat_id or _env(ENV_CHAT)
71
+ if not args.dry_run and not token:
72
+ sys.exit("Bot token is missing: --token or STREAMLENS_TOKEN (get one from @BotFather)")
73
+ if not chat_id:
74
+ sys.exit("Chat is missing: --chat-id or STREAMLENS_CHAT_ID")
75
+ split = [g.split("+") for g in args.split] if args.split else None
76
+ return Publisher(token, chat_id, state=args.state, lang=args.lang, tz=args.tz, mode=args.mode, split=split,
77
+ sort=args.sort, min_online=args.min_online, exclude=args.exclude or (),
78
+ empty_message=not args.no_empty_message, dry_run=args.dry_run)
79
+
80
+
81
+ def cmd_send(args) -> None:
82
+ pub = _publisher(args)
83
+ streams = load_streams(args.file)
84
+ ids = pub.publish(streams)
85
+ print(f"streams: {len(streams)}, messages sent: {len(ids)}")
86
+
87
+
88
+ def cmd_watch(args) -> None:
89
+ pub = _publisher(args)
90
+ with open(args.channels, encoding="utf-8") as f:
91
+ channels = parse_channels(f.read())
92
+ if not channels:
93
+ sys.exit(f"{args.channels}: no channels found")
94
+ twitch_id, twitch_secret = os.environ.get("TWITCH_CLIENT_ID"), os.environ.get("TWITCH_CLIENT_SECRET")
95
+ twitch = TwitchApi(twitch_id, twitch_secret) if twitch_id and twitch_secret else None
96
+ if not twitch and any("twitch.tv" in c["url"] for c in channels):
97
+ print("note: no TWITCH_CLIENT_ID / TWITCH_CLIENT_SECRET, so Twitch viewers are unknown (frames still work)")
98
+
99
+ while True:
100
+ started = time.monotonic()
101
+ try:
102
+ streams = probe_channels(channels, twitch=twitch, cookies_file=args.cookies)
103
+ ids = pub.publish(streams)
104
+ print(f"{dt.datetime.now():%Y-%m-%d %H:%M:%S} live: {len(streams)} of {len(channels)}, "
105
+ f"messages sent: {len(ids)}", flush=True)
106
+ except (MissingDependency, ValueError):
107
+ raise
108
+ except TelegramError as e:
109
+ print(f"telegram error: {e}", file=sys.stderr, flush=True)
110
+ if args.once:
111
+ return
112
+ time.sleep(max(1, args.every * 60 - (time.monotonic() - started)))
113
+
114
+
115
+ def cmd_check(args) -> None:
116
+ token, chat_id = args.token or _env(ENV_TOKEN), args.chat_id or _env(ENV_CHAT)
117
+ if not token or not chat_id:
118
+ sys.exit("Need --token and --chat-id (or STREAMLENS_TOKEN / STREAMLENS_CHAT_ID)")
119
+ client = TelegramClient(token)
120
+ try:
121
+ me = client.get_me()
122
+ print(f"token ok: @{me.get('username')}")
123
+ msg = client.send_text(chat_id, "✅ streamlens: the bot can post here")
124
+ client.delete(chat_id, msg["message_id"])
125
+ print(f"chat {chat_id} ok: test message sent and removed")
126
+ except TelegramError as e:
127
+ sys.exit(f"failed: {e}")
128
+
129
+
130
+ def cmd_clear(args) -> None:
131
+ pub = _publisher(args)
132
+ print(f"deleted messages: {pub.clear()}")
133
+
134
+
135
+ def _add_common(p: argparse.ArgumentParser) -> None:
136
+ p.add_argument("--token", help="bot token (or env STREAMLENS_TOKEN)")
137
+ p.add_argument("--chat-id", help="chat / channel / user id (or env STREAMLENS_CHAT_ID)")
138
+ p.add_argument("--state", default=os.environ.get("STREAMLENS_STATE", "streamlens.db"),
139
+ help="SQLite file with message ids and frames (default: ./streamlens.db)")
140
+ p.add_argument("--lang", default="en", choices=("en", "ru"))
141
+ p.add_argument("--tz", default="UTC", help="time zone of the footer, e.g. Europe/Moscow")
142
+ p.add_argument("--mode", default="auto", choices=("auto", "rich", "text"),
143
+ help="rich = message with frames, text = plain text, auto = rich with a text fallback")
144
+ p.add_argument("--split", action="append", metavar="PLATFORMS",
145
+ help="separate message for these platforms, e.g. --split twitch+kick --split youtube")
146
+ p.add_argument("--sort", default="asc", choices=("asc", "desc"), help="asc: the biggest stream is at the bottom")
147
+ p.add_argument("--min-online", type=int, default=0, help="hide streams with fewer viewers")
148
+ p.add_argument("--exclude", action="append", metavar="NAME_OR_URL", help="never show this stream (repeatable)")
149
+ p.add_argument("--no-empty-message", action="store_true", help="do not post 'nobody is live'")
150
+ p.add_argument("--dry-run", action="store_true", help="print the message instead of sending it")
151
+
152
+
153
+ def main(argv: Optional[List[str]] = None) -> None:
154
+ parser = argparse.ArgumentParser(prog="streamlens", description="Live streams in one Telegram message.")
155
+ parser.add_argument("--version", action="version", version=f"streamlens {__version__}")
156
+ sub = parser.add_subparsers(dest="command", required=True)
157
+
158
+ p = sub.add_parser("send", help="publish streams from a JSON/CSV file (or - for stdin)")
159
+ p.add_argument("file")
160
+ _add_common(p)
161
+ p.set_defaults(func=cmd_send)
162
+
163
+ p = sub.add_parser("watch", help="check a list of channels every N minutes and publish who is live")
164
+ p.add_argument("channels", help="text file: one channel URL per line, optionally 'Name | URL'")
165
+ p.add_argument("--every", type=float, default=5, help="minutes between updates (default 5)")
166
+ p.add_argument("--once", action="store_true", help="run one update and exit (for cron)")
167
+ p.add_argument("--cookies", default=os.environ.get("STREAMLENS_YT_COOKIES"),
168
+ help="YouTube cookies.txt, used only for age-restricted streams")
169
+ _add_common(p)
170
+ p.set_defaults(func=cmd_watch)
171
+
172
+ p = sub.add_parser("check", help="verify the token and that the bot can post to the chat")
173
+ p.add_argument("--token")
174
+ p.add_argument("--chat-id")
175
+ p.set_defaults(func=cmd_check)
176
+
177
+ p = sub.add_parser("clear", help="delete the messages posted by the bot (from the local state)")
178
+ _add_common(p)
179
+ p.set_defaults(func=cmd_clear)
180
+
181
+ args = parser.parse_args(argv)
182
+ try:
183
+ args.func(args)
184
+ except MissingDependency as e:
185
+ sys.exit(str(e))
186
+ except TelegramError as e:
187
+ sys.exit(f"telegram error: {e}")
188
+ except KeyboardInterrupt:
189
+ sys.exit(130)
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()
streamlens/i18n.py ADDED
@@ -0,0 +1,35 @@
1
+ """Texts of the message. Add a language by adding a dict here."""
2
+ from __future__ import annotations
3
+
4
+ TEXTS = {
5
+ "en": {
6
+ "live_now": "🔴 Live now ({n})",
7
+ "nobody": "❌ Nobody is live right now",
8
+ "updated": "updated {time}",
9
+ "watch": "Watch stream",
10
+ "no_frames": "no frames yet",
11
+ "now": "now",
12
+ "min_ago": "min ago",
13
+ "days": "d",
14
+ "hours": "h",
15
+ "minutes": "min",
16
+ },
17
+ "ru": {
18
+ "live_now": "🔴 Сейчас в эфире ({n})",
19
+ "nobody": "❌ Сейчас никого нет в эфире",
20
+ "updated": "обновлено {time}",
21
+ "watch": "Смотреть стрим",
22
+ "no_frames": "кадров пока нет",
23
+ "now": "сейчас",
24
+ "min_ago": "мин назад",
25
+ "days": "дн",
26
+ "hours": "ч",
27
+ "minutes": "мин",
28
+ },
29
+ }
30
+
31
+
32
+ def texts(lang: str) -> dict:
33
+ if lang not in TEXTS:
34
+ raise ValueError(f"unknown lang {lang!r}, available: {', '.join(TEXTS)}")
35
+ return TEXTS[lang]
streamlens/message.py ADDED
@@ -0,0 +1,173 @@
1
+ """
2
+ Building the message. Pure functions, no network and no state: easy to test and to reuse.
3
+
4
+ The main format is a Telegram *rich message* (Bot API ``sendRichMessage``): platform groups, every stream is a
5
+ collapsible block with a slideshow of frames "now / 5 / 10 / 15 / 20 min ago". ``build_text_message`` is a plain
6
+ HTML fallback for bots/clients without rich messages.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import datetime as dt
11
+ import html as html_lib
12
+ from typing import Dict, List, Optional, Sequence, Tuple
13
+
14
+ from .i18n import texts
15
+ from .models import Frame, Stream
16
+
17
+ FRAMES_PER_STREAM = 5
18
+ # Telegram limit: the 51st picture in one rich message fails with "Request Entity Too Large"
19
+ MAX_MEDIA_PER_MESSAGE = 50
20
+
21
+ PLATFORM_TITLES = (
22
+ ("youtube", "▶️ YOUTUBE"),
23
+ ("twitch", "🟣 TWITCH"),
24
+ ("kick", "🟢 KICK"),
25
+ ("vk", "🔵 VK VIDEO"),
26
+ ("other", "🌐 OTHER"),
27
+ )
28
+
29
+ RichMessage = Dict[str, object]
30
+ Files = Dict[str, Tuple[str, bytes, str]]
31
+
32
+
33
+ def esc(text: Optional[str]) -> str:
34
+ return html_lib.escape(text or "", quote=True)
35
+
36
+
37
+ def fmt_num(n: int) -> str:
38
+ if n >= 1_000_000:
39
+ return f"{n / 1_000_000:.1f}M"
40
+ if n >= 10_000:
41
+ return f"{n / 1_000:.1f}k"
42
+ return str(n)
43
+
44
+
45
+ def delta_str(diff: int) -> str:
46
+ if diff > 0:
47
+ return f"🟢 +{diff}"
48
+ if diff < 0:
49
+ return f"🔴 {diff}"
50
+ return "🟡 0"
51
+
52
+
53
+ def format_duration(started_at: Optional[dt.datetime], now: dt.datetime, lang: str = "en") -> str:
54
+ """⏱️ how long the stream goes. 24/7 streams (years) are shown in days."""
55
+ if not started_at:
56
+ return ""
57
+ t = texts(lang)
58
+ minutes = max(0, int((now - started_at).total_seconds() // 60))
59
+ if minutes >= 24 * 60:
60
+ return f"⏱️{minutes // (24 * 60)} {t['days']}"
61
+ if minutes >= 60:
62
+ return f"⏱️{minutes // 60} {t['hours']} {minutes % 60:02d} {t['minutes']}"
63
+ return f"⏱️{minutes} {t['minutes']}"
64
+
65
+
66
+ def ages_label(ages: Sequence[int], lang: str = "en") -> str:
67
+ """[0, 5, 10] -> '📷 now / 5 / 10 min ago'."""
68
+ t = texts(lang)
69
+ nums = [str(a) for a in ages]
70
+ if ages[0] <= 2:
71
+ nums[0] = t["now"]
72
+ suffix = "" if len(nums) == 1 and nums[0] == t["now"] else " " + t["min_ago"]
73
+ return "📷 " + " / ".join(nums) + suffix
74
+
75
+
76
+ def plan_frames(streams: Sequence[Stream], per_stream: int = FRAMES_PER_STREAM,
77
+ max_media: int = MAX_MEDIA_PER_MESSAGE) -> Tuple[int, set]:
78
+ """
79
+ How many frames per stream and who gets them, to fit into the picture limit of one message.
80
+ Few streams: full slideshow for each; more: 4, 3, 2, then 1 frame; above the limit only the biggest streams
81
+ get a frame (``streams`` are sorted ascending, the biggest are last).
82
+ """
83
+ for per in range(per_stream, 0, -1):
84
+ if len(streams) * per <= max_media:
85
+ return per, {s.url for s in streams}
86
+ return 1, {s.url for s in streams[-max_media:]}
87
+
88
+
89
+ def _summary(s: Stream) -> str:
90
+ text = f'📺 <b><a href="{esc(s.channel)}">{esc(s.name)}</a></b>'
91
+ if s.online is not None:
92
+ text += f" | {s.online} 👁"
93
+ if s.online_diff is not None:
94
+ text += f" {delta_str(s.online_diff)}"
95
+ return text
96
+
97
+
98
+ def _stream_block(n: int, s: Stream, frames: Sequence[Frame], now: dt.datetime, lang: str,
99
+ media: list, files: Files) -> str:
100
+ t = texts(lang)
101
+ if not frames: # no frames yet (the stream has just appeared or a frame could not be taken)
102
+ credit = " · ".join(x for x in (esc(s.title), format_duration(s.started_at, now, lang)) if x)
103
+ tail = f" · {t['no_frames']}" + (f" · {credit}" if credit else "")
104
+ return (f'<details><summary>{_summary(s)}</summary>'
105
+ f'<p><a href="{esc(s.watch_url or s.channel)}">{t["watch"]}</a>{tail}</p></details>')
106
+
107
+ imgs = []
108
+ for k, f in enumerate(frames):
109
+ key = f"s{n}_{k}"
110
+ files[f"f_{key}"] = (f"{key}.jpg", f.image, "image/jpeg")
111
+ media.append({"id": key, "media": {"type": "photo", "media": f"attach://f_{key}"}})
112
+ imgs.append(f'<img src="tg://photo?id={key}"/>')
113
+
114
+ latest = frames[0]
115
+ title = s.title or latest.title
116
+ started = s.started_at or latest.started_at
117
+ credit = " · ".join(x for x in (
118
+ esc(title), format_duration(started, now, lang), ages_label([f.age_min for f in frames], lang)) if x)
119
+ link = latest.watch_url or s.watch_url or s.channel
120
+ return (f'<details><summary>{_summary(s)}</summary><tg-slideshow>{"".join(imgs)}'
121
+ f'<figcaption><a href="{esc(link)}">{t["watch"]}</a><cite>{credit}</cite></figcaption>'
122
+ f'</tg-slideshow></details>')
123
+
124
+
125
+ def build_rich_message(streams: Sequence[Stream], frames: Dict[str, List[Frame]], now: dt.datetime,
126
+ time_label: str, lang: str = "en") -> Tuple[RichMessage, Files]:
127
+ """
128
+ Returns ``(rich_message, files)`` for ``sendRichMessage``.
129
+ ``streams`` must be sorted already. ``frames`` maps stream url -> frames, newest first.
130
+ """
131
+ t = texts(lang)
132
+ footer = f"<footer>{t['updated'].format(time=time_label)}</footer>"
133
+ if not streams:
134
+ return {"html": f"<h3>{t['nobody']}</h3>{footer}"}, {}
135
+
136
+ per_stream, with_frames = plan_frames(streams)
137
+ media: list = []
138
+ files: Files = {}
139
+ blocks = []
140
+ numbered = list(enumerate(streams))
141
+ for key, title in PLATFORM_TITLES:
142
+ group = [(n, s) for n, s in numbered if s.platform == key]
143
+ if not group:
144
+ continue
145
+ total = sum(s.online or 0 for _, s in group)
146
+ blocks.append(f"<h4>{title} — {len(group)} • {fmt_num(total)}</h4>")
147
+ for n, s in group:
148
+ fr = frames.get(s.url, [])[:per_stream] if s.url in with_frames else []
149
+ blocks.append(_stream_block(n, s, fr, now, lang, media, files))
150
+
151
+ body = f"<h3>{t['live_now'].format(n=len(streams))}</h3>" + "".join(blocks) + footer
152
+ rich: RichMessage = {"html": body}
153
+ if media:
154
+ rich["media"] = media
155
+ return rich, files
156
+
157
+
158
+ def build_text_message(streams: Sequence[Stream], time_label: str, lang: str = "en") -> str:
159
+ """Plain HTML for ``sendMessage``: the fallback when rich messages are not available."""
160
+ t = texts(lang)
161
+ footer = t["updated"].format(time=time_label)
162
+ if not streams:
163
+ return f"{t['nobody']}\n<i>{footer}</i>"
164
+ lines = [f"<b>{t['live_now'].format(n=len(streams))}</b>"]
165
+ for key, title in PLATFORM_TITLES:
166
+ group = [s for s in streams if s.platform == key]
167
+ if not group:
168
+ continue
169
+ lines += ["", f"<b>{title}</b> — {len(group)} • {fmt_num(sum(s.online or 0 for s in group))}"]
170
+ for s in group:
171
+ lines.append(_summary(s))
172
+ lines += ["", f"<i>{footer}</i>"]
173
+ return "\n".join(lines)
streamlens/models.py ADDED
@@ -0,0 +1,81 @@
1
+ """Data model: a live stream that you want to show in the chat."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as dt
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+ from urllib.parse import urlparse
8
+
9
+ PLATFORMS = ("youtube", "twitch", "kick", "vk", "other")
10
+
11
+ _HOSTS = {
12
+ "youtube": ("youtube.com", "youtu.be"),
13
+ "twitch": ("twitch.tv",),
14
+ "kick": ("kick.com",),
15
+ "vk": ("vkvideo.ru", "vk.com", "vk.ru"),
16
+ }
17
+
18
+
19
+ def detect_platform(url: str) -> str:
20
+ """'youtube' | 'twitch' | 'kick' | 'vk' | 'other' by the URL host."""
21
+ host = (urlparse(url).hostname or "").lower()
22
+ for platform, domains in _HOSTS.items():
23
+ if any(host == d or host.endswith("." + d) for d in domains):
24
+ return platform
25
+ return "other"
26
+
27
+
28
+ def channel_url(url: str) -> str:
29
+ """Channel link without the trailing /live."""
30
+ stripped = url.rstrip("/")
31
+ return stripped[: -len("/live")] if stripped.endswith("/live") else url
32
+
33
+
34
+ @dataclass
35
+ class Stream:
36
+ """
37
+ One live stream.
38
+
39
+ Only ``name`` and ``url`` are required. Everything else makes the message richer:
40
+
41
+ online current viewers (``None`` if unknown, e.g. Kick without an API key)
42
+ online_diff change vs. ~10 minutes ago; computed from the local state when omitted
43
+ image JPEG/PNG bytes of a fresh frame; the last few are kept and shown as a slideshow
44
+ title stream title
45
+ started_at when the stream began (aware datetime, or naive = UTC); shown as duration
46
+ watch_url direct link to the stream (defaults to ``url``)
47
+ platform detected from ``url`` when omitted
48
+ """
49
+
50
+ name: str
51
+ url: str
52
+ online: Optional[int] = None
53
+ platform: Optional[str] = None
54
+ online_diff: Optional[int] = None
55
+ image: Optional[bytes] = None
56
+ title: Optional[str] = None
57
+ started_at: Optional[dt.datetime] = None
58
+ watch_url: Optional[str] = None
59
+
60
+ def __post_init__(self) -> None:
61
+ if not self.platform:
62
+ self.platform = detect_platform(self.url)
63
+ if self.online is not None:
64
+ self.online = int(self.online)
65
+ if self.started_at is not None and self.started_at.tzinfo is None:
66
+ self.started_at = self.started_at.replace(tzinfo=dt.timezone.utc)
67
+
68
+ @property
69
+ def channel(self) -> str:
70
+ return channel_url(self.url)
71
+
72
+
73
+ @dataclass
74
+ class Frame:
75
+ """A stored frame of a stream; ``age_min`` is filled when it is loaded for a message."""
76
+
77
+ image: bytes
78
+ age_min: int = 0
79
+ watch_url: Optional[str] = None
80
+ title: Optional[str] = None
81
+ started_at: Optional[dt.datetime] = None
@@ -0,0 +1,145 @@
1
+ """Publisher: takes the current list of streams and keeps the chat message(s) up to date."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as dt
5
+ import os
6
+ from typing import Iterable, List, Optional, Sequence, Union
7
+ from zoneinfo import ZoneInfo
8
+
9
+ from .message import FRAMES_PER_STREAM, build_rich_message, build_text_message
10
+ from .models import Stream
11
+ from .state import State
12
+ from .telegram import DryRunClient, TelegramClient, TelegramError
13
+
14
+ FRAMES_WINDOW_MIN = 25 # frames older than this are not shown
15
+ FRAMES_KEEP_MIN = 30 # ...and are deleted after this
16
+
17
+
18
+ class Publisher:
19
+ """
20
+ >>> pub = Publisher(token="123:ABC", chat_id="-100123")
21
+ >>> pub.publish([Stream(name="Some channel", url="https://www.twitch.tv/some", online=1200)])
22
+
23
+ Call ``publish`` on a schedule (every ~5 minutes works best: that is the step between the frames of the
24
+ slideshow). Every call sends the new message first and then deletes the previous one, so a failed send never
25
+ leaves the chat empty.
26
+
27
+ token / chat_id bot token and chat (a group, a channel where the bot is admin, or your own user id)
28
+ state path of the SQLite file with message ids and frames (``":memory:"`` for none)
29
+ lang ``"en"`` or ``"ru"``
30
+ tz time zone of the "updated" footer, e.g. ``"Europe/Moscow"``
31
+ mode ``"rich"`` rich message with frames, ``"text"`` plain text, ``"auto"`` rich with fallback to text
32
+ split several messages instead of one, e.g. ``[["twitch", "kick"], ["youtube"]]``; the first one is
33
+ posted first, the last one ends up at the bottom of the chat
34
+ sort ``"asc"`` smaller streams first, the biggest at the bottom near the input field; or ``"desc"``
35
+ min_online hide streams with fewer viewers (streams with unknown viewers are kept)
36
+ exclude names or urls that are never shown
37
+ empty_message post "nobody is live" when the list is empty (otherwise the old message is just removed)
38
+ dry_run print instead of sending; nothing is sent to Telegram
39
+ """
40
+
41
+ def __init__(self, token: Optional[str] = None, chat_id: Union[str, int, None] = None, *,
42
+ state: Union[str, os.PathLike] = "streamlens.db", lang: str = "en", tz: str = "UTC",
43
+ mode: str = "auto", split: Optional[Sequence[Sequence[str]]] = None, sort: str = "asc",
44
+ min_online: int = 0, exclude: Iterable[str] = (), empty_message: bool = True,
45
+ dry_run: bool = False, client=None):
46
+ if mode not in ("auto", "rich", "text"):
47
+ raise ValueError("mode must be 'auto', 'rich' or 'text'")
48
+ if sort not in ("asc", "desc"):
49
+ raise ValueError("sort must be 'asc' or 'desc'")
50
+ if chat_id in (None, ""):
51
+ raise ValueError("chat_id is required")
52
+ self.chat_id = str(chat_id)
53
+ self.client = client or (DryRunClient() if dry_run else TelegramClient(token or ""))
54
+ self.state = State(state)
55
+ self.lang = lang
56
+ self.tzinfo = ZoneInfo(tz)
57
+ self.tz = tz
58
+ self.mode = mode
59
+ self.slots = [("+".join(g), tuple(g)) for g in split] if split else [("all", None)]
60
+ self.sort = sort
61
+ self.min_online = min_online
62
+ self.exclude = set(exclude)
63
+ self.empty_message = empty_message
64
+
65
+ # ------------------------------------------------------------------------------------------------------
66
+ def publish(self, streams: Sequence[Stream], now: Optional[dt.datetime] = None) -> List[int]:
67
+ """Updates the chat. Returns ids of the sent messages."""
68
+ now = now or dt.datetime.now(dt.timezone.utc)
69
+ streams = self._prepare(streams, now)
70
+ frames = self.state.load_frames([s.url for s in streams], now, FRAMES_WINDOW_MIN, FRAMES_PER_STREAM)
71
+ time_label = f"{now.astimezone(self.tzinfo):%H:%M} {self.tz if self.tz != 'UTC' else 'UTC'}"
72
+
73
+ sent: List[int] = []
74
+ for n, (slot, platforms) in enumerate(self.slots):
75
+ group = [s for s in streams if platforms is None or s.platform in platforms]
76
+ prev = self.state.last_message(self.chat_id, slot)
77
+
78
+ nobody_at_all = n == 0 and not streams and self.empty_message
79
+ if not group and not nobody_at_all:
80
+ if prev:
81
+ self._drop(prev)
82
+ continue
83
+
84
+ message_id = self._send(group, frames, now, time_label)
85
+ self.state.remember_message(self.chat_id, slot, message_id, now)
86
+ sent.append(message_id)
87
+ if prev: # new message first, old one after: a failed send keeps the old message
88
+ self._drop(prev)
89
+
90
+ self.state.prune(now, FRAMES_KEEP_MIN)
91
+ return sent
92
+
93
+ def clear(self) -> int:
94
+ """Deletes all messages the bot has posted to this chat (from the local state). Returns how many."""
95
+ count = 0
96
+ for slot, _ in self.slots:
97
+ prev = self.state.last_message(self.chat_id, slot)
98
+ if prev:
99
+ self._drop(prev)
100
+ count += 1
101
+ return count
102
+
103
+ # ------------------------------------------------------------------------------------------------------
104
+ def _prepare(self, streams: Sequence[Stream], now: dt.datetime) -> List[Stream]:
105
+ kept = [s for s in streams
106
+ if s.name not in self.exclude and s.url not in self.exclude
107
+ and (s.online is None or s.online >= self.min_online)]
108
+ seen, unique = set(), []
109
+ for s in kept: # one entry per url
110
+ if s.url not in seen:
111
+ seen.add(s.url)
112
+ unique.append(s)
113
+
114
+ for s in unique:
115
+ if s.image:
116
+ self.state.add_frame(s.url, s.image, now, watch_url=s.watch_url, title=s.title,
117
+ started_at=s.started_at)
118
+ if s.online is not None:
119
+ if s.online_diff is None:
120
+ before = self.state.online_before(s.url, now)
121
+ if before is not None:
122
+ s.online_diff = s.online - before
123
+ self.state.record_online(s.url, s.online, now)
124
+
125
+ unique.sort(key=lambda s: s.online or 0, reverse=self.sort == "desc")
126
+ return unique
127
+
128
+ def _send(self, group: Sequence[Stream], frames, now: dt.datetime, time_label: str) -> int:
129
+ if self.mode != "text":
130
+ rich, files = build_rich_message(group, frames, now, time_label, self.lang)
131
+ try:
132
+ return self.client.send_rich(self.chat_id, rich, files)["message_id"]
133
+ except TelegramError as e:
134
+ if self.mode == "rich" or e.error_code != 404: # 404: this Bot API has no rich messages
135
+ raise
136
+ return self.client.send_text(self.chat_id, build_text_message(group, time_label, self.lang))["message_id"]
137
+
138
+ def _drop(self, message_id: int) -> None:
139
+ self.client.delete(self.chat_id, message_id)
140
+ self.state.forget_message(self.chat_id, message_id)
141
+
142
+
143
+ def publish(streams: Sequence[Stream], token: str, chat_id: Union[str, int], **options) -> List[int]:
144
+ """One-shot helper: ``publish(streams, token, chat_id, lang="ru")``. See :class:`Publisher` for options."""
145
+ return Publisher(token, chat_id, **options).publish(streams)
streamlens/sources.py ADDED
@@ -0,0 +1,212 @@
1
+ """
2
+ Optional: find out by yourself which channels are live, with viewers and a fresh frame.
3
+ Used by ``streamlens watch``; the core library does not need it (you can pass your own data to ``Publisher``).
4
+
5
+ YouTube yt-dlp (``pip install streamlens[frames]``): live flag, viewers, title, start time, frame from the stream
6
+ Kick yt-dlp: live flag, title, frame (Kick does not expose the viewer count without an API key)
7
+ Twitch public preview picture (no key needed) shows that the channel is live and gives a frame;
8
+ viewers, title and start time need a free Twitch app: TWITCH_CLIENT_ID / TWITCH_CLIENT_SECRET
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import datetime as dt
13
+ import os
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ from concurrent.futures import ThreadPoolExecutor
18
+ from typing import Dict, List, Optional, Sequence
19
+
20
+ import requests
21
+
22
+ from .models import Stream, detect_platform
23
+
24
+ MIN_HEIGHT = 360 # enough for a preview and a small file
25
+ AGE_GATE_MARK = "confirm your age"
26
+
27
+
28
+ class MissingDependency(RuntimeError):
29
+ pass
30
+
31
+
32
+ class _SilentLogger:
33
+ """yt-dlp prints 'ERROR: ... not currently live' for an offline channel; that is a normal answer for us."""
34
+
35
+ def debug(self, msg):
36
+ pass
37
+
38
+ warning = error = debug
39
+
40
+
41
+ def _ytdlp():
42
+ try:
43
+ import yt_dlp
44
+ except ImportError:
45
+ raise MissingDependency("yt-dlp is required for YouTube/Kick: pip install 'streamlens[frames]'") from None
46
+ return yt_dlp
47
+
48
+
49
+ def _ffmpeg() -> str:
50
+ path = shutil.which("ffmpeg")
51
+ if not path:
52
+ raise MissingDependency("ffmpeg is required for YouTube/Kick frames: https://ffmpeg.org/download.html")
53
+ return path
54
+
55
+
56
+ def _utc(ts: Optional[float]) -> Optional[dt.datetime]:
57
+ return dt.datetime.fromtimestamp(ts, dt.timezone.utc) if ts else None
58
+
59
+
60
+ def _clean_title(title: Optional[str]) -> str:
61
+ """yt-dlp appends the date and time to the title of live streams; drop it."""
62
+ return re.sub(r"\s\d{4}-\d{2}-\d{2} \d{2}:\d{2}$", "", title or "")
63
+
64
+
65
+ # --- YouTube / Kick via yt-dlp -----------------------------------------------------------------------------------
66
+ def _extract_info(url: str, cookies_file: Optional[str]) -> dict:
67
+ yt_dlp = _ytdlp()
68
+ opts = {"quiet": True, "no_warnings": True, "skip_download": True, "logger": _SilentLogger()}
69
+ if shutil.which("node"): # JS runtime that YouTube extraction needs
70
+ opts["js_runtimes"] = {"node": {}}
71
+ try:
72
+ with yt_dlp.YoutubeDL(opts) as ydl:
73
+ return ydl.extract_info(url, download=False)
74
+ except yt_dlp.utils.DownloadError as e:
75
+ # cookies are used only when YouTube asks to confirm the age
76
+ if AGE_GATE_MARK not in str(e) or not cookies_file or not os.path.isfile(cookies_file):
77
+ raise
78
+ with yt_dlp.YoutubeDL({**opts, "cookiefile": cookies_file}) as ydl:
79
+ return ydl.extract_info(url, download=False)
80
+
81
+
82
+ def probe_ytdlp(url: str, name: Optional[str] = None, cookies_file: Optional[str] = None) -> Optional[Stream]:
83
+ """A YouTube/Kick channel as a Stream with a frame, or None when it is not live."""
84
+ yt_dlp = _ytdlp()
85
+ try:
86
+ info = _extract_info(url, cookies_file)
87
+ except yt_dlp.utils.DownloadError as e:
88
+ if "not currently live" in str(e) or "not live" in str(e).lower() or "live event will begin" in str(e):
89
+ return None
90
+ raise
91
+ if not info.get("is_live"):
92
+ return None
93
+
94
+ formats = [f for f in info.get("formats", []) if f.get("vcodec") not in (None, "none") and f.get("height")]
95
+ if not formats:
96
+ return None
97
+ good = sorted((f for f in formats if f["height"] >= MIN_HEIGHT), key=lambda f: f["height"])
98
+ fmt = good[0] if good else max(formats, key=lambda f: f["height"])
99
+ proc = subprocess.run([_ffmpeg(), "-loglevel", "error", "-i", fmt["url"], "-frames:v", "1", "-q:v", "4",
100
+ "-f", "image2", "-c:v", "mjpeg", "pipe:1"], capture_output=True, timeout=60)
101
+ image = proc.stdout if proc.returncode == 0 and proc.stdout else None
102
+
103
+ return Stream(
104
+ name=name or info.get("channel") or info.get("uploader") or info.get("uploader_id") or url,
105
+ url=url, online=info.get("concurrent_view_count"), image=image,
106
+ title=_clean_title(info.get("title")), watch_url=info.get("webpage_url"),
107
+ started_at=_utc(info.get("release_timestamp") or info.get("timestamp")))
108
+
109
+
110
+ # --- Twitch --------------------------------------------------------------------------------------------------------
111
+ def twitch_login(url: str) -> str:
112
+ return url.rstrip("/").rsplit("/", 1)[-1].lower()
113
+
114
+
115
+ def twitch_preview(login: str, timeout: float = 15) -> Optional[bytes]:
116
+ """Public preview of a live channel. Offline channels redirect to a placeholder, then None."""
117
+ r = requests.get(f"https://static-cdn.jtvnw.net/previews-ttv/live_user_{login}-640x360.jpg", timeout=timeout)
118
+ if r.status_code != 200 or "404_preview" in r.url or not r.content:
119
+ return None
120
+ return r.content
121
+
122
+
123
+ class TwitchApi:
124
+ """Helix API with client credentials (create a free app at https://dev.twitch.tv/console)."""
125
+
126
+ def __init__(self, client_id: str, client_secret: str, timeout: float = 15):
127
+ self.client_id, self.client_secret, self.timeout = client_id, client_secret, timeout
128
+ self._headers: Optional[dict] = None
129
+
130
+ def _auth(self) -> dict:
131
+ if not self._headers:
132
+ r = requests.post("https://id.twitch.tv/oauth2/token", timeout=self.timeout, data={
133
+ "client_id": self.client_id, "client_secret": self.client_secret, "grant_type": "client_credentials"})
134
+ r.raise_for_status()
135
+ self._headers = {"Client-ID": self.client_id, "Authorization": f"Bearer {r.json()['access_token']}"}
136
+ return self._headers
137
+
138
+ def live(self, logins: Sequence[str]) -> Dict[str, dict]:
139
+ """{login: helix stream item} for channels that are live now."""
140
+ result: Dict[str, dict] = {}
141
+ for i in range(0, len(logins), 100):
142
+ r = requests.get("https://api.twitch.tv/helix/streams", headers=self._auth(), timeout=self.timeout,
143
+ params=[("user_login", x) for x in logins[i:i + 100]])
144
+ r.raise_for_status()
145
+ for item in r.json().get("data", []):
146
+ if item.get("type") == "live":
147
+ result[item["user_login"].lower()] = item
148
+ return result
149
+
150
+
151
+ def probe_twitch(channels: Sequence[Dict[str, str]], api: Optional[TwitchApi] = None) -> List[Stream]:
152
+ """channels: [{"name":..., "url":...}]. Returns the live ones."""
153
+ by_login = {twitch_login(c["url"]): c for c in channels}
154
+ live = api.live(list(by_login)) if api else {}
155
+ streams = []
156
+ for login, c in by_login.items():
157
+ item = live.get(login)
158
+ if api and not item:
159
+ continue
160
+ image = twitch_preview(login)
161
+ if not api and not image: # without the API the preview is the only sign that the channel is live
162
+ continue
163
+ started = None
164
+ if item:
165
+ started = dt.datetime.fromisoformat(item["started_at"].replace("Z", "+00:00"))
166
+ streams.append(Stream(
167
+ name=c.get("name") or (item or {}).get("user_name") or login, url=c["url"], platform="twitch",
168
+ online=item["viewer_count"] if item else None, image=image,
169
+ title=(item or {}).get("title"), started_at=started, watch_url=f"https://www.twitch.tv/{login}"))
170
+ return streams
171
+
172
+
173
+ # --- everything together -------------------------------------------------------------------------------------------
174
+ def parse_channels(text: str) -> List[Dict[str, str]]:
175
+ """Lines of ``https://...`` or ``Name | https://...``; blank lines and ``#`` comments are ignored."""
176
+ channels = []
177
+ for raw in text.splitlines():
178
+ line = re.sub(r"\s+#.*$", "", raw.strip())
179
+ if not line or line.startswith("#"):
180
+ continue
181
+ name, _, url = line.rpartition("|")
182
+ channels.append({"name": name.strip(), "url": url.strip()})
183
+ return channels
184
+
185
+
186
+ def probe_channels(channels: Sequence[Dict[str, str]], *, twitch: Optional[TwitchApi] = None,
187
+ cookies_file: Optional[str] = None, workers: int = 2, log=print) -> List[Stream]:
188
+ """Which of the channels are live now. One broken channel never breaks the others."""
189
+ streams: List[Stream] = []
190
+ twitch_channels = [c for c in channels if detect_platform(c["url"]) == "twitch"]
191
+ others = [c for c in channels if detect_platform(c["url"]) in ("youtube", "kick")]
192
+ unsupported = [c for c in channels if c not in twitch_channels and c not in others]
193
+ for c in unsupported:
194
+ log(f" ! {c['url']}: platform is not supported in watch mode, pass such streams to Publisher yourself")
195
+
196
+ try:
197
+ streams += probe_twitch(twitch_channels, twitch)
198
+ except Exception as e:
199
+ log(f" ! twitch: {str(e)[:200]}")
200
+
201
+ def one(c: Dict[str, str]) -> Optional[Stream]:
202
+ try:
203
+ return probe_ytdlp(c["url"], c.get("name") or None, cookies_file)
204
+ except MissingDependency:
205
+ raise
206
+ except Exception as e:
207
+ log(f" ! {c['url']}: {str(e)[:200]}")
208
+ return None
209
+
210
+ with ThreadPoolExecutor(max_workers=workers) as pool:
211
+ streams += [s for s in pool.map(one, others) if s]
212
+ return streams
streamlens/state.py ADDED
@@ -0,0 +1,100 @@
1
+ """
2
+ Local state in one SQLite file (standard library, no server): ids of the messages the bot posted, the latest frames of
3
+ every stream and a short history of viewer counts (to show the change over ~10 minutes).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import datetime as dt
8
+ import os
9
+ import sqlite3
10
+ from typing import Dict, List, Optional, Sequence, Union
11
+
12
+ from .models import Frame
13
+
14
+ SCHEMA = """
15
+ create table if not exists messages (
16
+ chat_id text not null, slot text not null, message_id integer not null, ts real not null
17
+ );
18
+ create table if not exists frames (
19
+ url text not null, ts real not null, image blob not null,
20
+ watch_url text, title text, started_at real
21
+ );
22
+ create index if not exists frames_url_ts on frames (url, ts desc);
23
+ create table if not exists online (url text not null, ts real not null, online integer not null);
24
+ create index if not exists online_url_ts on online (url, ts desc);
25
+ """
26
+
27
+
28
+ def _ts(t: dt.datetime) -> float:
29
+ return t.timestamp()
30
+
31
+
32
+ def _from_ts(x: Optional[float]) -> Optional[dt.datetime]:
33
+ return dt.datetime.fromtimestamp(x, dt.timezone.utc) if x is not None else None
34
+
35
+
36
+ class State:
37
+ def __init__(self, path: Union[str, os.PathLike] = "streamlens.db"):
38
+ self.db = sqlite3.connect(str(path))
39
+ self.db.executescript(SCHEMA)
40
+
41
+ def close(self) -> None:
42
+ self.db.close()
43
+
44
+ # --- messages -------------------------------------------------------------------------------------------
45
+ def last_message(self, chat_id: str, slot: str) -> Optional[int]:
46
+ row = self.db.execute("select message_id from messages where chat_id=? and slot=? order by ts desc limit 1",
47
+ (chat_id, slot)).fetchone()
48
+ return row[0] if row else None
49
+
50
+ def remember_message(self, chat_id: str, slot: str, message_id: int, now: dt.datetime) -> None:
51
+ self.db.execute("insert into messages values (?, ?, ?, ?)", (chat_id, slot, message_id, _ts(now)))
52
+ self.db.commit()
53
+
54
+ def forget_message(self, chat_id: str, message_id: int) -> None:
55
+ self.db.execute("delete from messages where chat_id=? and message_id=?", (chat_id, message_id))
56
+ self.db.commit()
57
+
58
+ # --- frames ---------------------------------------------------------------------------------------------
59
+ def add_frame(self, url: str, image: bytes, now: dt.datetime, *, watch_url: Optional[str] = None,
60
+ title: Optional[str] = None, started_at: Optional[dt.datetime] = None,
61
+ min_gap_min: float = 3) -> None:
62
+ """Stores a frame. A frame that is younger than ``min_gap_min`` is replaced, so quick reruns do not
63
+ fill the slideshow with duplicates."""
64
+ self.db.execute("delete from frames where url=? and ts > ?", (url, _ts(now) - min_gap_min * 60))
65
+ self.db.execute("insert into frames values (?, ?, ?, ?, ?, ?)",
66
+ (url, _ts(now), image, watch_url, title, _ts(started_at) if started_at else None))
67
+ self.db.commit()
68
+
69
+ def load_frames(self, urls: Sequence[str], now: dt.datetime, window_min: float,
70
+ limit: int) -> Dict[str, List[Frame]]:
71
+ """{url: [Frame, ...]}, newest first, at most ``limit`` per stream, younger than ``window_min``."""
72
+ result: Dict[str, List[Frame]] = {}
73
+ for url in urls:
74
+ rows = self.db.execute(
75
+ "select ts, image, watch_url, title, started_at from frames where url=? and ts > ? "
76
+ "order by ts desc limit ?", (url, _ts(now) - window_min * 60, limit)).fetchall()
77
+ if rows:
78
+ result[url] = [Frame(image=bytes(img), age_min=int(round((_ts(now) - ts) / 60)), watch_url=w,
79
+ title=title, started_at=_from_ts(st)) for ts, img, w, title, st in rows]
80
+ return result
81
+
82
+ # --- viewers history ------------------------------------------------------------------------------------
83
+ def record_online(self, url: str, online: int, now: dt.datetime) -> None:
84
+ self.db.execute("insert into online values (?, ?, ?)", (url, _ts(now), online))
85
+ self.db.commit()
86
+
87
+ def online_before(self, url: str, now: dt.datetime, min_age_min: float = 8,
88
+ max_age_min: float = 20) -> Optional[int]:
89
+ """Viewer count from ~10 minutes ago (the freshest record within the window), or None."""
90
+ row = self.db.execute(
91
+ "select online from online where url=? and ts <= ? and ts >= ? order by ts desc limit 1",
92
+ (url, _ts(now) - min_age_min * 60, _ts(now) - max_age_min * 60)).fetchone()
93
+ return row[0] if row else None
94
+
95
+ # --- housekeeping ---------------------------------------------------------------------------------------
96
+ def prune(self, now: dt.datetime, frames_keep_min: float = 30) -> None:
97
+ self.db.execute("delete from frames where ts < ?", (_ts(now) - frames_keep_min * 60,))
98
+ self.db.execute("delete from online where ts < ?", (_ts(now) - 24 * 3600,))
99
+ self.db.execute("delete from messages where ts < ?", (_ts(now) - 3 * 24 * 3600,))
100
+ self.db.commit()
streamlens/telegram.py ADDED
@@ -0,0 +1,90 @@
1
+ """Minimal Telegram Bot API client (only what streamlens needs). The bot token never ends up in error messages."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import time
6
+ from typing import Any, Dict, Optional
7
+
8
+ import requests
9
+
10
+ API_BASE = "https://api.telegram.org"
11
+
12
+
13
+ class TelegramError(RuntimeError):
14
+ def __init__(self, method: str, description: str, error_code: Optional[int] = None):
15
+ super().__init__(f"{method}: {description}")
16
+ self.method = method
17
+ self.description = description
18
+ self.error_code = error_code
19
+
20
+
21
+ class TelegramClient:
22
+ def __init__(self, token: str, *, api_base: str = API_BASE, timeout: float = 120,
23
+ session: Optional[requests.Session] = None):
24
+ if not token:
25
+ raise ValueError("Telegram bot token is empty")
26
+ self._url = f"{api_base.rstrip('/')}/bot{token}"
27
+ self.timeout = timeout
28
+ self.session = session or requests.Session()
29
+
30
+ def call(self, method: str, data: Optional[Dict[str, Any]] = None, files: Optional[dict] = None) -> Any:
31
+ for attempt in (1, 2):
32
+ try:
33
+ r = self.session.post(f"{self._url}/{method}", data=data, files=files or None, timeout=self.timeout)
34
+ payload = r.json()
35
+ except (requests.RequestException, ValueError) as e:
36
+ # do not chain: the original exception text contains the URL with the token
37
+ raise TelegramError(method, f"request failed ({type(e).__name__})") from None
38
+ if payload.get("ok"):
39
+ return payload["result"]
40
+ retry_after = (payload.get("parameters") or {}).get("retry_after")
41
+ if payload.get("error_code") == 429 and retry_after and attempt == 1 and retry_after <= 30:
42
+ time.sleep(retry_after + 1)
43
+ continue
44
+ raise TelegramError(method, payload.get("description", "unknown error"), payload.get("error_code"))
45
+
46
+ def get_me(self) -> dict:
47
+ return self.call("getMe")
48
+
49
+ def send_rich(self, chat_id: str, rich: dict, files: Optional[dict] = None) -> dict:
50
+ return self.call("sendRichMessage", {"chat_id": chat_id, "rich_message": json.dumps(rich)}, files)
51
+
52
+ def send_text(self, chat_id: str, html: str) -> dict:
53
+ return self.call("sendMessage", {"chat_id": chat_id, "text": html, "parse_mode": "HTML",
54
+ "disable_web_page_preview": True})
55
+
56
+ def delete(self, chat_id: str, message_id: int) -> None:
57
+ """Deleting is best effort: the message may be already gone or older than 48 hours."""
58
+ try:
59
+ self.call("deleteMessage", {"chat_id": chat_id, "message_id": message_id})
60
+ except TelegramError:
61
+ pass
62
+
63
+
64
+ class DryRunClient:
65
+ """Prints what would be sent instead of sending. Used by ``--dry-run`` and in tests."""
66
+
67
+ def __init__(self, out=print):
68
+ self.out = out
69
+ self.sent: list = []
70
+ self._next_id = 1
71
+
72
+ def _id(self) -> dict:
73
+ self._next_id += 1
74
+ return {"message_id": self._next_id - 1}
75
+
76
+ def get_me(self) -> dict:
77
+ return {"username": "dry_run_bot"}
78
+
79
+ def send_rich(self, chat_id: str, rich: dict, files: Optional[dict] = None) -> dict:
80
+ self.sent.append(("rich", chat_id, rich, files or {}))
81
+ self.out(f"[dry-run] sendRichMessage to {chat_id}: {len(files or {})} picture(s)\n{rich['html']}")
82
+ return self._id()
83
+
84
+ def send_text(self, chat_id: str, html: str) -> dict:
85
+ self.sent.append(("text", chat_id, html, {}))
86
+ self.out(f"[dry-run] sendMessage to {chat_id}:\n{html}")
87
+ return self._id()
88
+
89
+ def delete(self, chat_id: str, message_id: int) -> None:
90
+ self.out(f"[dry-run] deleteMessage {message_id} in {chat_id}")
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.5
2
+ Name: streamlens
3
+ Version: 0.1.0
4
+ Summary: Live-stream tracker for Telegram: one auto-updating rich message with frames from YouTube, Twitch and Kick streams
5
+ Project-URL: Homepage, https://github.com/klipbn/streamlens
6
+ Project-URL: Issues, https://github.com/klipbn/streamlens/issues
7
+ Project-URL: Changelog, https://github.com/klipbn/streamlens/blob/main/CHANGELOG.md
8
+ Author: Alexey Voronko
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: bot,kick,livestream,monitoring,telegram,twitch,youtube
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Communications :: Chat
18
+ Classifier: Topic :: Multimedia :: Video
19
+ Requires-Python: >=3.9
20
+ Requires-Dist: requests>=2.25
21
+ Requires-Dist: tzdata; platform_system == 'Windows'
22
+ Provides-Extra: dev
23
+ Requires-Dist: build; extra == 'dev'
24
+ Requires-Dist: pytest>=7; extra == 'dev'
25
+ Requires-Dist: ruff>=0.5; extra == 'dev'
26
+ Requires-Dist: twine; extra == 'dev'
27
+ Provides-Extra: frames
28
+ Requires-Dist: yt-dlp[default]>=2025.1.1; extra == 'frames'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # streamlens
32
+
33
+ [![PyPI](https://img.shields.io/pypi/v/streamlens.svg?style=flat-square)](https://pypi.org/project/streamlens/)
34
+ [![Python](https://img.shields.io/pypi/pyversions/streamlens.svg?style=flat-square)](https://pypi.org/project/streamlens/)
35
+ [![CI](https://img.shields.io/github/actions/workflow/status/klipbn/streamlens/ci.yml?style=flat-square&label=tests)](https://github.com/klipbn/streamlens/actions)
36
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg?style=flat-square)](LICENSE)
37
+
38
+ **Live streams in one Telegram message that keeps itself up to date.**
39
+ Hand it a list of streams (or a list of channels) and a bot token; it posts who is live, how many viewers they have,
40
+ how that changed, and a slideshow of frames from the last 20 minutes for each stream.
41
+
42
+ - **No server, no database.** Pure Python; state is one small SQLite file.
43
+ - **Bring your own data** (`Publisher.publish([...])`) **or let it watch channels** for you (`streamlens watch`).
44
+ - **YouTube, Twitch, Kick** out of the box; any other platform if you pass the numbers yourself.
45
+ - **Safe updates:** the new message is sent first, then the old one is deleted; a failed send never empties the chat.
46
+ - English and Russian texts, time zone for the footer, size limits of Telegram handled for you.
47
+
48
+ It grew out of a production Airflow + Postgres pipeline that tracks streams around the clock; this is the same
49
+ message format, extracted into something you can `pip install`.
50
+
51
+ ## Quick start
52
+
53
+ ```bash
54
+ pip install streamlens
55
+ ```
56
+
57
+ You need a bot token from [@BotFather](https://t.me/BotFather) and a chat id. Add the bot to your group (or make it
58
+ an admin of your channel) and check that it can post:
59
+
60
+ ```bash
61
+ export STREAMLENS_TOKEN="123456:ABC..."
62
+ export STREAMLENS_CHAT_ID="-1001234567890" # group / channel id, or your own user id
63
+ streamlens check
64
+ ```
65
+
66
+ > How to find the chat id: add [@RawDataBot](https://t.me/RawDataBot) to the group, or open
67
+ > `https://api.telegram.org/bot<TOKEN>/getUpdates` after writing anything to your bot.
68
+
69
+ ### 1. You have the data
70
+
71
+ ```python
72
+ from streamlens import Publisher, Stream
73
+
74
+ publisher = Publisher(token="123456:ABC...", chat_id="-1001234567890", lang="en", tz="Europe/Berlin")
75
+
76
+ publisher.publish([
77
+ Stream(name="Some Twitch channel", url="https://www.twitch.tv/some_channel", online=12400),
78
+ Stream(name="Some YouTube channel", url="https://www.youtube.com/@some_channel/live", online=830,
79
+ title="Late night talk", image=open("frame.jpg", "rb").read()),
80
+ ])
81
+ ```
82
+
83
+ Call `publish` every ~5 minutes (cron, Airflow, a loop). Only `name` and `url` are required; the platform is detected
84
+ from the URL. Each call stores the frame you pass in `image`, so the slideshow *now / 5 / 10 / 15 / 20 min ago*
85
+ builds up by itself. The change in viewers versus ~10 minutes ago is computed from the history the same way.
86
+
87
+ From the shell, with a JSON or CSV file (or `-` for stdin):
88
+
89
+ ```bash
90
+ streamlens send examples/streams.json
91
+ cat streams.csv | streamlens send - # columns: name,url,online[,title,image,started_at,...]
92
+ streamlens send examples/streams.json --dry-run --chat-id 1 # print instead of sending
93
+ ```
94
+
95
+ ### 2. You only have channel links
96
+
97
+ ```bash
98
+ pip install "streamlens[frames]" # + ffmpeg on PATH, for YouTube/Kick frames
99
+ streamlens watch examples/channels.txt --every 5
100
+ ```
101
+
102
+ `channels.txt` is one URL per line (`Name | URL` to set a display name). Every 5 minutes it finds out who is live,
103
+ grabs a frame and updates the message.
104
+
105
+ | Platform | Live status | Viewers | Frame | Notes |
106
+ |---|---|---|---|---|
107
+ | YouTube | yt-dlp | yes | from the stream (ffmpeg) | use the channel `/live` URL |
108
+ | Twitch | public preview | with a free [Twitch app](https://dev.twitch.tv/console): set `TWITCH_CLIENT_ID`, `TWITCH_CLIENT_SECRET` | public preview | works without keys, just no viewer count |
109
+ | Kick | yt-dlp | no (Kick has no public count) | from the stream (ffmpeg) | pass your own numbers via `Publisher` if you have them |
110
+
111
+ Age-restricted YouTube streams need a `cookies.txt` of a YouTube account (`--cookies`); it is used only for those.
112
+
113
+ ## Options
114
+
115
+ | Option (`Publisher(...)` / CLI flag) | Default | What it does |
116
+ |---|---|---|
117
+ | `lang` / `--lang` | `en` | `en` or `ru` |
118
+ | `tz` / `--tz` | `UTC` | time zone of the "updated" footer, e.g. `Europe/Moscow` |
119
+ | `mode` / `--mode` | `auto` | `rich` (frames), `text` (plain), `auto` = rich, falls back to text if the Bot API has no rich messages |
120
+ | `split` / `--split` | one message | several messages, e.g. `--split twitch+kick --split youtube` |
121
+ | `sort` / `--sort` | `asc` | `asc`: the biggest stream ends up at the bottom, next to the input field |
122
+ | `min_online` / `--min-online` | `0` | hide small streams |
123
+ | `exclude` / `--exclude` | none | names or URLs that are never shown |
124
+ | `state` / `--state` | `./streamlens.db` | where message ids and frames are kept |
125
+
126
+ `streamlens clear` deletes the messages the bot posted. `--dry-run` works everywhere and never touches Telegram.
127
+
128
+ ## Deploy
129
+
130
+ Anything that runs a command every few minutes works. Pick one.
131
+
132
+ **Long-running process (systemd, tmux, a VPS):** `streamlens watch channels.txt --every 5`
133
+
134
+ **cron:** `*/5 * * * * streamlens watch /path/channels.txt --once` (env vars in the crontab or an env file)
135
+
136
+ **Docker:**
137
+
138
+ ```bash
139
+ cp examples/channels.txt channels.local.txt # edit
140
+ printf 'STREAMLENS_TOKEN=...\nSTREAMLENS_CHAT_ID=...\n' > .env
141
+ docker compose up -d
142
+ ```
143
+
144
+ The image contains ffmpeg and Node.js (the JS runtime that yt-dlp needs for YouTube). State lives in a volume.
145
+
146
+ **Existing pipeline (Airflow, a script):** import `Publisher` and call `publish()` with your data on your schedule.
147
+
148
+ ## Telegram limits it handles for you
149
+
150
+ - One rich message takes at most 50 pictures. With many streams the frames per stream go 5 → 4 → 3 → 2 → 1, and above
151
+ 50 streams only the biggest ones get a frame.
152
+ - Telegram clients fold long rich messages behind "Show more" after roughly 20 pictures in the visible part. This is a
153
+ client behaviour; no Bot API setting for it is documented. Measured on real messages: the fewer frames per stream, the more streams stay visible before the fold.
154
+ - Bot tokens never appear in error messages or logs.
155
+
156
+ ## Development
157
+
158
+ ```bash
159
+ git clone https://github.com/klipbn/streamlens && cd streamlens
160
+ pip install -e ".[dev,frames]"
161
+ pytest && ruff check .
162
+ ```
163
+
164
+ MIT © Alexey Voronko. Russian version: [README.ru.md](README.ru.md).
@@ -0,0 +1,14 @@
1
+ streamlens/__init__.py,sha256=h7A2EkhceHO_MtKgsnlP4RH5Nrf1wYVXVX_3Jws2cRQ,461
2
+ streamlens/cli.py,sha256=R8-xhBbV-aX3Sqt46Vqg3-gNP27Lq-J-Fd_i3Bj2wzc,8516
3
+ streamlens/i18n.py,sha256=Xct2rY3JzsPZ7JrGwhRnStC6oRyUMZ35cxSrxIE1g3g,1066
4
+ streamlens/message.py,sha256=iCehpzMZ0WTUCMhMYexwMEGnAl072DrvBpaHsjUs0PI,6747
5
+ streamlens/models.py,sha256=Yu_WEfYVTZGoTulePgAc4EwlsKHAChBreJMVJ8TFwAQ,2607
6
+ streamlens/publisher.py,sha256=gC8MmrPxY68MAGNjQ_2TMF7LLenj9IdCJMLzYO6uIxc,7362
7
+ streamlens/sources.py,sha256=m-7CyyzlHdp1t14A74Yo8JgpZB0-nOXs5N-qP4vUBQM,9322
8
+ streamlens/state.py,sha256=hoG_BV6VQCMzFBTdTFifP8rohGMB-lJlnTrgnJAWrYg,5100
9
+ streamlens/telegram.py,sha256=4cFD2V9zmhxK-RfPcbQjfGSJOSoyEACt3gELdvJTlok,3776
10
+ streamlens-0.1.0.dist-info/METADATA,sha256=zv3mCJ6BUHXyl-5ZyVN0e8gXAvjk9VWaC2RdcyGe4ek,7657
11
+ streamlens-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
12
+ streamlens-0.1.0.dist-info/entry_points.txt,sha256=L30qJy98Ah1QwJMPrXmFInDOOnjMWClpQIGP0ErKad0,51
13
+ streamlens-0.1.0.dist-info/licenses/LICENSE,sha256=b-ZbQXBE550xg9jmMQDaT5cN5of_fRGnGGBMtJqVtEo,1071
14
+ streamlens-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ streamlens = streamlens.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexey Voronko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.