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 +12 -0
- streamlens/cli.py +193 -0
- streamlens/i18n.py +35 -0
- streamlens/message.py +173 -0
- streamlens/models.py +81 -0
- streamlens/publisher.py +145 -0
- streamlens/sources.py +212 -0
- streamlens/state.py +100 -0
- streamlens/telegram.py +90 -0
- streamlens-0.1.0.dist-info/METADATA +164 -0
- streamlens-0.1.0.dist-info/RECORD +14 -0
- streamlens-0.1.0.dist-info/WHEEL +4 -0
- streamlens-0.1.0.dist-info/entry_points.txt +2 -0
- streamlens-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
streamlens/publisher.py
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/streamlens/)
|
|
34
|
+
[](https://pypi.org/project/streamlens/)
|
|
35
|
+
[](https://github.com/klipbn/streamlens/actions)
|
|
36
|
+
[](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,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.
|