streamlens 0.1.0__tar.gz

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.
@@ -0,0 +1,18 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .DS_Store
4
+ .idea/
5
+ .vscode/
6
+ .venv/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+
13
+ # local runtime data: never commit
14
+ *.db
15
+ .env
16
+ cookies.txt
17
+ yt_cookies.txt
18
+ channels.local.txt
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First public release. A rewrite of the private Airflow + Postgres bot into a standalone library.
6
+
7
+ - `Publisher` / `publish()`: one auto-updating Telegram message (new message first, then the old one is deleted)
8
+ - Rich message with platform groups and per-stream slideshow "now / 5 / 10 / 15 / 20 min ago"; plain-text fallback
9
+ - Local state in a single SQLite file: message ids, recent frames, viewer history (change over ~10 min)
10
+ - CLI: `send` (JSON/CSV/stdin), `watch` (poll a channel list), `check`, `clear`, `--dry-run`
11
+ - Sources for `watch`: YouTube and Kick via yt-dlp + ffmpeg, Twitch via public previews or the Helix API
12
+ - Languages: `en`, `ru`
@@ -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.
@@ -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,134 @@
1
+ # streamlens
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/streamlens.svg?style=flat-square)](https://pypi.org/project/streamlens/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/streamlens.svg?style=flat-square)](https://pypi.org/project/streamlens/)
5
+ [![CI](https://img.shields.io/github/actions/workflow/status/klipbn/streamlens/ci.yml?style=flat-square&label=tests)](https://github.com/klipbn/streamlens/actions)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg?style=flat-square)](LICENSE)
7
+
8
+ **Live streams in one Telegram message that keeps itself up to date.**
9
+ 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,
10
+ how that changed, and a slideshow of frames from the last 20 minutes for each stream.
11
+
12
+ - **No server, no database.** Pure Python; state is one small SQLite file.
13
+ - **Bring your own data** (`Publisher.publish([...])`) **or let it watch channels** for you (`streamlens watch`).
14
+ - **YouTube, Twitch, Kick** out of the box; any other platform if you pass the numbers yourself.
15
+ - **Safe updates:** the new message is sent first, then the old one is deleted; a failed send never empties the chat.
16
+ - English and Russian texts, time zone for the footer, size limits of Telegram handled for you.
17
+
18
+ It grew out of a production Airflow + Postgres pipeline that tracks streams around the clock; this is the same
19
+ message format, extracted into something you can `pip install`.
20
+
21
+ ## Quick start
22
+
23
+ ```bash
24
+ pip install streamlens
25
+ ```
26
+
27
+ You need a bot token from [@BotFather](https://t.me/BotFather) and a chat id. Add the bot to your group (or make it
28
+ an admin of your channel) and check that it can post:
29
+
30
+ ```bash
31
+ export STREAMLENS_TOKEN="123456:ABC..."
32
+ export STREAMLENS_CHAT_ID="-1001234567890" # group / channel id, or your own user id
33
+ streamlens check
34
+ ```
35
+
36
+ > How to find the chat id: add [@RawDataBot](https://t.me/RawDataBot) to the group, or open
37
+ > `https://api.telegram.org/bot<TOKEN>/getUpdates` after writing anything to your bot.
38
+
39
+ ### 1. You have the data
40
+
41
+ ```python
42
+ from streamlens import Publisher, Stream
43
+
44
+ publisher = Publisher(token="123456:ABC...", chat_id="-1001234567890", lang="en", tz="Europe/Berlin")
45
+
46
+ publisher.publish([
47
+ Stream(name="Some Twitch channel", url="https://www.twitch.tv/some_channel", online=12400),
48
+ Stream(name="Some YouTube channel", url="https://www.youtube.com/@some_channel/live", online=830,
49
+ title="Late night talk", image=open("frame.jpg", "rb").read()),
50
+ ])
51
+ ```
52
+
53
+ Call `publish` every ~5 minutes (cron, Airflow, a loop). Only `name` and `url` are required; the platform is detected
54
+ from the URL. Each call stores the frame you pass in `image`, so the slideshow *now / 5 / 10 / 15 / 20 min ago*
55
+ builds up by itself. The change in viewers versus ~10 minutes ago is computed from the history the same way.
56
+
57
+ From the shell, with a JSON or CSV file (or `-` for stdin):
58
+
59
+ ```bash
60
+ streamlens send examples/streams.json
61
+ cat streams.csv | streamlens send - # columns: name,url,online[,title,image,started_at,...]
62
+ streamlens send examples/streams.json --dry-run --chat-id 1 # print instead of sending
63
+ ```
64
+
65
+ ### 2. You only have channel links
66
+
67
+ ```bash
68
+ pip install "streamlens[frames]" # + ffmpeg on PATH, for YouTube/Kick frames
69
+ streamlens watch examples/channels.txt --every 5
70
+ ```
71
+
72
+ `channels.txt` is one URL per line (`Name | URL` to set a display name). Every 5 minutes it finds out who is live,
73
+ grabs a frame and updates the message.
74
+
75
+ | Platform | Live status | Viewers | Frame | Notes |
76
+ |---|---|---|---|---|
77
+ | YouTube | yt-dlp | yes | from the stream (ffmpeg) | use the channel `/live` URL |
78
+ | 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 |
79
+ | Kick | yt-dlp | no (Kick has no public count) | from the stream (ffmpeg) | pass your own numbers via `Publisher` if you have them |
80
+
81
+ Age-restricted YouTube streams need a `cookies.txt` of a YouTube account (`--cookies`); it is used only for those.
82
+
83
+ ## Options
84
+
85
+ | Option (`Publisher(...)` / CLI flag) | Default | What it does |
86
+ |---|---|---|
87
+ | `lang` / `--lang` | `en` | `en` or `ru` |
88
+ | `tz` / `--tz` | `UTC` | time zone of the "updated" footer, e.g. `Europe/Moscow` |
89
+ | `mode` / `--mode` | `auto` | `rich` (frames), `text` (plain), `auto` = rich, falls back to text if the Bot API has no rich messages |
90
+ | `split` / `--split` | one message | several messages, e.g. `--split twitch+kick --split youtube` |
91
+ | `sort` / `--sort` | `asc` | `asc`: the biggest stream ends up at the bottom, next to the input field |
92
+ | `min_online` / `--min-online` | `0` | hide small streams |
93
+ | `exclude` / `--exclude` | none | names or URLs that are never shown |
94
+ | `state` / `--state` | `./streamlens.db` | where message ids and frames are kept |
95
+
96
+ `streamlens clear` deletes the messages the bot posted. `--dry-run` works everywhere and never touches Telegram.
97
+
98
+ ## Deploy
99
+
100
+ Anything that runs a command every few minutes works. Pick one.
101
+
102
+ **Long-running process (systemd, tmux, a VPS):** `streamlens watch channels.txt --every 5`
103
+
104
+ **cron:** `*/5 * * * * streamlens watch /path/channels.txt --once` (env vars in the crontab or an env file)
105
+
106
+ **Docker:**
107
+
108
+ ```bash
109
+ cp examples/channels.txt channels.local.txt # edit
110
+ printf 'STREAMLENS_TOKEN=...\nSTREAMLENS_CHAT_ID=...\n' > .env
111
+ docker compose up -d
112
+ ```
113
+
114
+ The image contains ffmpeg and Node.js (the JS runtime that yt-dlp needs for YouTube). State lives in a volume.
115
+
116
+ **Existing pipeline (Airflow, a script):** import `Publisher` and call `publish()` with your data on your schedule.
117
+
118
+ ## Telegram limits it handles for you
119
+
120
+ - One rich message takes at most 50 pictures. With many streams the frames per stream go 5 → 4 → 3 → 2 → 1, and above
121
+ 50 streams only the biggest ones get a frame.
122
+ - Telegram clients fold long rich messages behind "Show more" after roughly 20 pictures in the visible part. This is a
123
+ 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.
124
+ - Bot tokens never appear in error messages or logs.
125
+
126
+ ## Development
127
+
128
+ ```bash
129
+ git clone https://github.com/klipbn/streamlens && cd streamlens
130
+ pip install -e ".[dev,frames]"
131
+ pytest && ruff check .
132
+ ```
133
+
134
+ MIT © Alexey Voronko. Russian version: [README.ru.md](README.ru.md).
@@ -0,0 +1,5 @@
1
+ # One channel per line. Optional display name before the pipe.
2
+ # YouTube: use the channel's /live URL. Twitch and Kick: the channel URL.
3
+ https://www.twitch.tv/twitch
4
+ Lofi Girl | https://www.youtube.com/@LofiGirl/live
5
+ https://kick.com/xqc
@@ -0,0 +1,20 @@
1
+ """Publish your own data: the library does not care where the numbers come from (an API, a database, a CSV)."""
2
+ import os
3
+
4
+ from streamlens import Publisher, Stream
5
+
6
+ publisher = Publisher(
7
+ token=os.environ["STREAMLENS_TOKEN"],
8
+ chat_id=os.environ["STREAMLENS_CHAT_ID"],
9
+ lang="en",
10
+ tz="Europe/Berlin",
11
+ )
12
+
13
+ streams = [
14
+ Stream(name="Some Twitch channel", url="https://www.twitch.tv/some_channel", online=12400),
15
+ Stream(name="Some YouTube channel", url="https://www.youtube.com/@some_channel/live", online=830,
16
+ title="Late night talk"),
17
+ ]
18
+
19
+ # Call this every ~5 minutes (cron, Airflow, a while-loop...). The previous message is replaced each time.
20
+ publisher.publish(streams)
@@ -0,0 +1,5 @@
1
+ [
2
+ {"name": "Some Twitch channel", "url": "https://www.twitch.tv/some_channel", "online": 12400, "title": "Ranked grind"},
3
+ {"name": "Some YouTube channel", "url": "https://www.youtube.com/@some_channel/live", "online": 830},
4
+ {"name": "Some Kick channel", "url": "https://kick.com/some_channel", "online": 95, "started_at": "2026-09-20T09:30:00Z"}
5
+ ]
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "streamlens"
7
+ version = "0.1.0"
8
+ description = "Live-stream tracker for Telegram: one auto-updating rich message with frames from YouTube, Twitch and Kick streams"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Alexey Voronko" }]
14
+ keywords = ["telegram", "bot", "livestream", "twitch", "youtube", "kick", "monitoring"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Topic :: Communications :: Chat",
22
+ "Topic :: Multimedia :: Video",
23
+ ]
24
+ dependencies = [
25
+ "requests>=2.25",
26
+ "tzdata; platform_system == 'Windows'",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ # Grab frames / viewer counts from YouTube and Kick (also needs ffmpeg on PATH)
31
+ frames = ["yt-dlp[default]>=2025.1.1"]
32
+ dev = ["pytest>=7", "ruff>=0.5", "build", "twine"]
33
+
34
+ [project.scripts]
35
+ streamlens = "streamlens.cli:main"
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/klipbn/streamlens"
39
+ Issues = "https://github.com/klipbn/streamlens/issues"
40
+ Changelog = "https://github.com/klipbn/streamlens/blob/main/CHANGELOG.md"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/streamlens"]
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ include = ["src", "tests", "examples", "README.md", "LICENSE", "CHANGELOG.md"]
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
50
+
51
+ [tool.ruff]
52
+ line-length = 120
53
+ src = ["src", "tests"]
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "I", "B", "UP"]
57
+ ignore = ["UP006", "UP007", "UP035", "UP045"] # keep typing compatible with 3.9 style
@@ -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
+ ]
@@ -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()