3 | smart-telegram-bot web
4 |
56 |
57 |
58 |
62 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/smart_tv_telegram/devices/device.py:
--------------------------------------------------------------------------------
1 | import abc
2 | import typing
3 |
4 | from aiohttp.web_request import Request
5 | from aiohttp.web_response import Response
6 |
7 | from .. import Config
8 |
9 |
10 | class RequestHandler(abc.ABC):
11 | @abc.abstractmethod
12 | def get_path(self) -> str:
13 | raise NotImplementedError
14 |
15 | @abc.abstractmethod
16 | async def handle(self, request: Request) -> Response:
17 | raise NotImplementedError
18 |
19 | @abc.abstractmethod
20 | def get_method(self) -> str:
21 | raise NotImplementedError
22 |
23 |
24 | RoutersDefType = typing.List[RequestHandler]
25 |
26 | __all__ = [
27 | "Device",
28 | "DeviceFinder",
29 | "RoutersDefType",
30 | "RequestHandler",
31 | "DevicePlayerFunction"
32 | ]
33 |
34 |
35 | class DevicePlayerFunction(abc.ABC):
36 | @abc.abstractmethod
37 | async def get_name(self) -> str:
38 | raise NotImplementedError
39 |
40 | @abc.abstractmethod
41 | async def handle(self):
42 | raise NotImplementedError
43 |
44 | @abc.abstractmethod
45 | async def is_enabled(self, config: Config):
46 | raise NotImplementedError
47 |
48 |
49 | class Device(abc.ABC):
50 | @abc.abstractmethod
51 | async def stop(self):
52 | raise NotImplementedError
53 |
54 | @abc.abstractmethod
55 | async def play(self, url: str, title: str, local_token: int):
56 | raise NotImplementedError
57 |
58 | @abc.abstractmethod
59 | def get_device_name(self) -> str:
60 | raise NotImplementedError
61 |
62 | @abc.abstractmethod
63 | def get_player_functions(self) -> typing.List[DevicePlayerFunction]:
64 | raise NotImplementedError
65 |
66 | @abc.abstractmethod
67 | def on_close(self, local_token: int):
68 | raise NotImplementedError
69 |
70 | def __repr__(self):
71 | return self.get_device_name()
72 |
73 |
74 | class DeviceFinder(abc.ABC):
75 | @abc.abstractmethod
76 | async def find(self, config: Config) -> typing.List[Device]:
77 | raise NotImplementedError
78 |
79 | @staticmethod
80 | @abc.abstractmethod
81 | def is_enabled(config: Config) -> bool:
82 | raise NotImplementedError
83 |
84 | @abc.abstractmethod
85 | async def get_routers(self, config: Config) -> RoutersDefType:
86 | raise NotImplementedError
87 |
--------------------------------------------------------------------------------
/smart_tv_telegram/devices/chromecast_device.py:
--------------------------------------------------------------------------------
1 | import typing
2 |
3 | import catt.api
4 |
5 | from . import Device, DeviceFinder, RoutersDefType, DevicePlayerFunction
6 | from .. import Config
7 | from ..tools import run_method_in_executor
8 |
9 | __all__ = [
10 | "ChromecastDevice",
11 | "ChromecastDeviceFinder"
12 | ]
13 |
14 |
15 | class ChromecastPlayFunction(DevicePlayerFunction):
16 | _device: catt.api.CattDevice
17 |
18 | def __init__(self, device: catt.api.CattDevice):
19 | self._device = device
20 |
21 | async def get_name(self) -> str:
22 | return "PLAY"
23 |
24 | async def handle(self):
25 | await run_method_in_executor(self._device.play)
26 |
27 | async def is_enabled(self, config: Config):
28 | return True
29 |
30 |
31 | class ChromecastPauseFunction(DevicePlayerFunction):
32 | _device: catt.api.CattDevice
33 |
34 | def __init__(self, device: catt.api.CattDevice):
35 | self._device = device
36 |
37 | async def get_name(self) -> str:
38 | return "PAUSE"
39 |
40 | async def handle(self):
41 | await run_method_in_executor(self._device.pause)
42 |
43 | async def is_enabled(self, config: Config):
44 | return True
45 |
46 |
47 | class ChromecastDevice(Device):
48 | _device: catt.api.CattDevice
49 |
50 | def __init__(self, device: catt.api.CattDevice):
51 | self._device = device
52 |
53 | def get_device_name(self) -> str:
54 | return self._device.name
55 |
56 | async def stop(self):
57 | pass
58 |
59 | async def on_close(self, local_token: int):
60 | await run_method_in_executor(self._device.stop)
61 |
62 | async def play(self, url: str, title: str, local_token: int):
63 | await run_method_in_executor(self._device.play_url, url, title=title)
64 |
65 | def get_player_functions(self) -> typing.List[DevicePlayerFunction]:
66 | return [
67 | ChromecastPlayFunction(self._device),
68 | ChromecastPauseFunction(self._device)
69 | ]
70 |
71 |
72 | class ChromecastDeviceFinder(DeviceFinder):
73 | _devices_cache: typing.Dict[str, catt.api.CattDevice]
74 |
75 | def __init__(self):
76 | self._devices_cache = {}
77 |
78 | async def find(self, config: Config) -> typing.List[Device]:
79 | found_devices: typing.List[catt.api.CattDevice] = await run_method_in_executor(catt.api.discover)
80 | cached_devices: typing.List[catt.api.CattDevice] = []
81 |
82 | for found_device in found_devices:
83 | cached_devices.append(self._devices_cache.setdefault(found_device.ip_addr, found_device))
84 |
85 | return [ChromecastDevice(device) for device in cached_devices]
86 |
87 | @staticmethod
88 | def is_enabled(config: Config) -> bool:
89 | return config.chromecast_enabled
90 |
91 | async def get_routers(self, config: Config) -> RoutersDefType:
92 | return []
93 |
--------------------------------------------------------------------------------
/smart_tv_telegram/__main__.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import configparser
3 | import logging
4 | import argparse
5 | import os.path
6 | import sys
7 | import traceback
8 | import typing
9 | import urllib.request
10 |
11 | from smart_tv_telegram import Http, Mtproto, Config, Bot, DeviceFinderCollection
12 | from smart_tv_telegram.devices import UpnpDeviceFinder, ChromecastDeviceFinder, VlcDeviceFinder, \
13 | WebDeviceFinder, XbmcDeviceFinder
14 |
15 |
16 | def open_config(parser: argparse.ArgumentParser, arg: str) -> typing.Optional[Config]:
17 | if not os.path.exists(arg):
18 | parser.error(f"The file `{arg}` does not exist")
19 |
20 | elif not os.path.isfile(arg):
21 | parser.error(f"`{arg}` is not a file")
22 |
23 | try:
24 | return Config(arg)
25 | except ValueError as err:
26 | parser.error(str(err))
27 | except KeyError as err:
28 | parser.error(f"config key {str(err)} does not exists")
29 | except configparser.Error as err:
30 | parser.error(f"generic configparser error:\n{str(err)}")
31 |
32 |
33 | async def async_main(config: Config, devices: DeviceFinderCollection):
34 | mtproto = Mtproto(config)
35 | http = Http(mtproto, config, devices)
36 | bot = Bot(mtproto, config, http, devices)
37 | http.set_on_stream_closed_handler(bot.get_on_stream_closed())
38 | bot.prepare()
39 |
40 | await mtproto.start()
41 | await http.start()
42 |
43 |
44 | def main(config: Config, devices: DeviceFinderCollection):
45 | loop = asyncio.get_event_loop()
46 | loop.run_until_complete(async_main(config, devices))
47 |
48 |
49 | def health_check(config: Config):
50 | # noinspection PyBroadException
51 | try:
52 | urllib.request.urlopen(f"http://{config.listen_host}:{config.listen_port}/healthcheck")
53 | except Exception:
54 | traceback.print_exc()
55 | return 1
56 |
57 | return 0
58 |
59 |
60 | def arg_parser(devices: DeviceFinderCollection):
61 | parser = argparse.ArgumentParser()
62 | parser.add_argument("-c", "--config", type=lambda x: open_config(parser, x), default="config.ini")
63 | parser.add_argument("-v", "--verbosity", type=int, choices=[0, 1, 2], default=0)
64 | parser.add_argument("-hc", "--healthcheck", type=bool, default=False, const=True, nargs="?")
65 |
66 | args = parser.parse_args()
67 |
68 | if args.verbosity == 0:
69 | logging.basicConfig(level=logging.ERROR)
70 |
71 | elif args.verbosity == 1:
72 | logging.basicConfig(level=logging.INFO)
73 |
74 | elif args.verbosity == 2:
75 | logging.basicConfig(level=logging.DEBUG)
76 |
77 | if args.healthcheck:
78 | sys.exit(health_check(args.config))
79 |
80 | main(args.config, devices)
81 |
82 |
83 | def entry_point():
84 | _devices = DeviceFinderCollection()
85 | _devices.register_finder(UpnpDeviceFinder())
86 | _devices.register_finder(ChromecastDeviceFinder())
87 | _devices.register_finder(VlcDeviceFinder())
88 | _devices.register_finder(WebDeviceFinder())
89 | _devices.register_finder(XbmcDeviceFinder())
90 |
91 | arg_parser(_devices)
92 |
93 |
94 | if __name__ == "__main__":
95 | entry_point()
96 |
--------------------------------------------------------------------------------
/smart_tv_telegram/devices/vlc_device.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import io
3 | import logging
4 | import typing
5 |
6 | from . import DeviceFinder, Device, RoutersDefType, DevicePlayerFunction
7 | from .. import Config
8 |
9 | __all__ = [
10 | "VlcDevice",
11 | "VlcDeviceFinder"
12 | ]
13 |
14 | _LOGGER = logging.getLogger(__name__)
15 | _ENCODING = "utf8"
16 | _EOF = b"\n\r"
17 | _AUTH_MAGIC = b"\xff\xfb\x01"
18 | _AUTH_OK = b"\xff\xfc\x01\r\nWelcome"
19 |
20 |
21 | class VlcDeviceParams:
22 | _host: str
23 | _port: int
24 | _password: typing.Optional[str] = None
25 |
26 | def __init__(self, params: typing.Dict[str, typing.Any]):
27 | self._host = params["host"]
28 | self._port = params["port"]
29 |
30 | if "password" in params:
31 | self._password = params["password"]
32 |
33 | @property
34 | def host(self) -> str:
35 | return self._host
36 |
37 | @property
38 | def port(self) -> int:
39 | return self._port
40 |
41 | @property
42 | def password(self) -> typing.Optional[str]:
43 | return self._password
44 |
45 |
46 | class VlcDevice(Device):
47 | params: VlcDeviceParams
48 |
49 | def __init__(self, device: VlcDeviceParams):
50 | self._params = device
51 |
52 | def get_device_name(self) -> str:
53 | return f"vlc @{self._params.host}"
54 |
55 | async def _call(self, method: str, *args: str):
56 | reader, writer = await asyncio.open_connection(self._params.host, self._params.port)
57 | headers = await reader.read(io.DEFAULT_BUFFER_SIZE)
58 |
59 | if headers.endswith(_AUTH_MAGIC):
60 | if self._params.password:
61 | writer.write(bytes(self._params.password, _ENCODING) + _EOF)
62 | await writer.drain()
63 |
64 | auth_result = await reader.read(io.DEFAULT_BUFFER_SIZE)
65 |
66 | if not auth_result.startswith(_AUTH_OK):
67 | _LOGGER.error("receive: %s", auth_result.decode(_ENCODING, "ignore"))
68 | return writer.close()
69 |
70 | else:
71 | _LOGGER.error("vlc %s: need password", self._params.host)
72 | return writer.close()
73 |
74 | writer.write(
75 | method.encode(_ENCODING) + b" " +
76 | b" ".join(a.encode(_ENCODING) for a in args) + _EOF
77 | )
78 |
79 | await writer.drain()
80 | return writer.close()
81 |
82 | async def stop(self):
83 | await self._call("stop")
84 |
85 | async def on_close(self, local_token: int):
86 | pass
87 |
88 | async def play(self, url: str, title: str, local_token: int):
89 | await self._call("add", url)
90 | await self._call("play")
91 |
92 | def get_player_functions(self) -> typing.List[DevicePlayerFunction]:
93 | return []
94 |
95 |
96 | class VlcDeviceFinder(DeviceFinder):
97 | async def find(self, config: Config) -> typing.List[Device]:
98 | return [
99 | VlcDevice(VlcDeviceParams(params))
100 | for params in config.vlc_devices
101 | ]
102 |
103 | @staticmethod
104 | def is_enabled(config: Config) -> bool:
105 | return config.vlc_enabled
106 |
107 | async def get_routers(self, config: Config) -> RoutersDefType:
108 | return []
109 |
--------------------------------------------------------------------------------
/smart_tv_telegram/tools.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import concurrent.futures
3 | import functools
4 | import re
5 | import secrets
6 | import typing
7 |
8 | from pyrogram.raw.types import MessageMediaDocument, Document, DocumentAttributeFilename
9 | from pyrogram.raw.types import Message as TlMessage
10 | from pyrogram.types import Message as BoxedMessage
11 |
12 | from . import Config
13 |
14 | __all__ = [
15 | "mtproto_filename",
16 | "build_uri",
17 | "ascii_only",
18 | "run_method_in_executor",
19 | "parse_http_range",
20 | "pyrogram_filename",
21 | "secret_token",
22 | "base_url",
23 | "serialize_token",
24 | "AsyncDebounce"
25 | ]
26 |
27 | _NAMED_MEDIA_TYPES = ("document", "video", "audio", "video_note", "animation")
28 | _RANGE_REGEX = re.compile(r"bytes=([0-9]+)-([0-9]+)?")
29 | _EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=1)
30 |
31 |
32 | async def _debounce_wrap(
33 | function: typing.Callable[..., typing.Coroutine],
34 | args: typing.Tuple[typing.Any],
35 | timeout: int,
36 | ):
37 | await asyncio.sleep(timeout)
38 | await function(*args)
39 |
40 |
41 | def base_url(config: Config) -> str:
42 | return f"http://{config.listen_host}:{config.listen_port}"
43 |
44 |
45 | class AsyncDebounce:
46 | _function: typing.Callable[..., typing.Coroutine]
47 | _timeout: int
48 | _task: typing.Optional[asyncio.Task] = None
49 | _args: typing.Optional[typing.Tuple[typing.Any]] = None
50 |
51 | def __init__(self, function: typing.Callable[..., typing.Coroutine], timeout: int):
52 | self._function = function
53 | self._timeout = timeout
54 |
55 | def _run(self) -> bool:
56 | if self._args is None:
57 | return False
58 |
59 | self._task = asyncio.get_event_loop().create_task(_debounce_wrap(self._function, self._args, self._timeout))
60 | return True
61 |
62 | def update_args(self, *args) -> bool:
63 | if self._task is not None and self._task.done():
64 | return False
65 |
66 | if self._task is not None:
67 | self._task.cancel()
68 |
69 | self._args = args
70 | return self._run()
71 |
72 | def reschedule(self):
73 | return self._run()
74 |
75 |
76 | def secret_token(nbytes: int = 8) -> int:
77 | return int.from_bytes(secrets.token_bytes(nbytes=nbytes), "big")
78 |
79 |
80 | def serialize_token(message_id: int, token: int) -> int:
81 | return (token << 64) ^ message_id
82 |
83 |
84 | def pyrogram_filename(message: BoxedMessage) -> str:
85 | try:
86 | return next(
87 | getattr(message, t).file_name
88 | for t in _NAMED_MEDIA_TYPES
89 | if getattr(message, t) is not None
90 | )
91 | except StopIteration as error:
92 | raise TypeError() from error
93 |
94 |
95 | def mtproto_filename(message: TlMessage) -> str:
96 | if not (
97 | isinstance(message.media, MessageMediaDocument) and
98 | isinstance(message.media.document, Document)
99 | ):
100 | raise TypeError()
101 |
102 | try:
103 | return next(
104 | attr.file_name
105 | for attr in message.media.document.attributes
106 | if isinstance(attr, DocumentAttributeFilename)
107 | )
108 | except StopIteration as error:
109 | raise TypeError() from error
110 |
111 |
112 | def build_uri(config: Config, msg_id: int, token: int) -> str:
113 | return f"http://{config.listen_host}:{config.listen_port}/stream/{msg_id}/{token}"
114 |
115 |
116 | def ascii_only(haystack: str) -> str:
117 | return "".join(c for c in haystack if ord(c) < 128)
118 |
119 |
120 | async def run_method_in_executor(func, *args, **kwargs):
121 | partial_function = functools.partial(func, *args, **kwargs)
122 | return await asyncio.get_event_loop().run_in_executor(_EXECUTOR, partial_function)
123 |
124 |
125 | def parse_http_range(http_range: str, block_size: int) -> typing.Tuple[int, int, typing.Optional[int]]:
126 | matches = _RANGE_REGEX.search(http_range)
127 |
128 | if matches is None:
129 | raise ValueError()
130 |
131 | offset = matches.group(1)
132 |
133 | if not offset.isdigit():
134 | raise ValueError()
135 |
136 | max_size = matches.group(2)
137 |
138 | if max_size and max_size.isdigit():
139 | max_size = int(max_size)
140 | else:
141 | max_size = None
142 |
143 | offset = int(offset)
144 | safe_offset = (offset // block_size) * block_size
145 | data_to_skip = offset - safe_offset
146 |
147 | return safe_offset, data_to_skip, max_size
148 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # smart-tv-telegram [](https://pypi.org/project/smart-tv-telegram/) [](https://github.com/andrew-ld/smart-tv-telegram/blob/master/LICENSE) [Docker](https://hub.docker.com/r/andrewhyphenld/smart-tv-telegram)
2 | A Telegram Bot to stream content on your smart TV (also Chromecast, FireTV and other UPnP device)
3 |
4 | ### Demonstration video
5 | [](https://player.vimeo.com/video/452289383)
6 |
7 |
8 | ## Feature
9 | - Streaming, the bot will not have to download the entire file before playing it on your device
10 | - You can play anything if your device has the right codec
11 | - You can stream on any device that supports UPnP (AVTransport)
12 | - Chromecast, Vlc (telnet api) and Kodi (xbmc http api) support
13 | - Streaming over HTTP
14 | - Web interface that plays videos in your browser
15 |
16 | Note: Chromecast (1st, 2nd and 3rd Gen.) [only supports H.264 and VP8 video codecs](https://developers.google.com/cast/docs/media#video_codecs)
17 |
18 | Note: Most LG TVs with WebOS have an incorrect UPnP implementation
19 |
20 | ## How-to setup (Release from pypi)
21 | Make sure you have an updated version of python, only the latest version will be supported
22 |
23 | - Install smart-tv-telegram from pip
24 | - Download config.ini.example as config.ini
25 | - Edit config.ini
26 | - Start from python entrypoint
27 |
28 | ```bash
29 | python3 -m pip install --upgrade smart-tv-telegram
30 | curl https://raw.githubusercontent.com/andrew-ld/smart-tv-telegram/master/config.ini.example -o config.ini
31 | nano config.ini
32 | smart_tv_telegram -c config.ini -v 1
33 | ```
34 |
35 | ## How-to setup (Manual build)
36 | Make sure you have an updated version of python, only the latest version will be supported
37 |
38 | - Download the repository
39 | - Build wheel package
40 | - Install package
41 | - Copy config.ini.example to config.ini
42 | - Edit config.ini
43 | - Start from python entrypoint
44 |
45 | ```bash
46 | git clone https://github.com/andrew-ld/smart-tv-telegram
47 | cd smart-tv-telegram
48 | python3 -m pip install wheel setuptools
49 | python3 setup.py sdist bdist_wheel
50 | python3 -m pip install dist/*.whl
51 | cp config.ini.example config.ini
52 | nano config.ini
53 | smart_tv_telegram -c config.ini -v 1
54 | ```
55 |
56 | ## How-to setup (Docker)
57 | - Copy config.ini.example to config.ini
58 | - Edit config.ini
59 | - Build Docker image
60 | - Start Docker container
61 |
62 | ```bash
63 | cp config.ini.example config.ini
64 | nano config.ini
65 | docker image build -t smart-tv-telegram:latest .
66 | docker run --network host -v "$(pwd)/config.ini:/app/config.ini:ro" -d smart-tv-telegram:latest
67 | ```
68 |
69 | ## Troubleshooting
70 |
71 | **Q:** How do I use the web interface?
72 |
73 | **A:** Set `enabled` to `1` in `web_ui` config block, and change the `password`
74 |
75 | - open http://`listen_ip`:`listen_port`/static/index.html
76 |
77 | - now if you send a video in the bot on telegram you can choose to play it in the browser
78 |
79 | ##
80 | **Q:** My Firewall block upnp and broadcasting, how can use kodi without it
81 |
82 | **A:** Set `xbmc_enabled` to `1` and add your kodi device to `xbmc_devices` list
83 |
84 | ##
85 | **Q:** What is the format of `xbmc_devices`
86 |
87 | **A:** A List of Python Dict with `host`, `port`, (and optional: `username` and `password`)
88 |
89 | **example:** `[{"host": "192.168.1.2", "port": 8080, "username": "pippo", "password": "pluto"}]`
90 |
91 | ##
92 | **Q:** How-To control vlc from this bot
93 |
94 | **A:** set `vlc_enabled` to `1` and add your vlc device to `vlc_devices` list
95 |
96 | ##
97 | **Q:** What is the format of `vlc_devices`
98 |
99 | **A:** A List of Python Dict with `host`, `port`, (and optional: `password`)
100 |
101 | **example:** `[{"host": "127.0.0.1", "port": 4212, "password": "123"}]`
102 |
103 |
104 | ##
105 | **Q:** How-To enable upnp on my device that use kodi
106 |
107 | **A:** follow [this guide](https://kodi.wiki/view/Settings/Services/UPnP_DLNA) (you should enable remote control)
108 |
109 | ##
110 | **Q:** How do I get a token?
111 |
112 | **A:** From [@BotFather](https://telegram.me/BotFather)
113 | ##
114 | **Q:** How do I set up admins?
115 |
116 | **A:** You have to enter your user_id, there are many ways to get it, the easiest is to use [@getuseridbot](https://telegram.me/getuseridbot)
117 | ##
118 | **Q:** How do I get an app_id and app_hash?
119 |
120 | **A:** https://core.telegram.org/api/obtaining_api_id#obtaining-api-id
121 | ##
122 | **Q:** The video keeps freezing
123 |
124 | **A:** Check the video bitrate, this bot supports maximum ~4.5Mb/s
125 |
--------------------------------------------------------------------------------
/smart_tv_telegram/devices/web_device.py:
--------------------------------------------------------------------------------
1 | import time
2 | import typing
3 |
4 | from aiohttp.web_request import Request
5 | from aiohttp.web_response import Response
6 |
7 | from smart_tv_telegram import Config
8 | from smart_tv_telegram.devices import DeviceFinder, RoutersDefType, Device, RequestHandler, DevicePlayerFunction
9 | from smart_tv_telegram.tools import secret_token
10 |
11 | __all__ = [
12 | "WebDeviceFinder",
13 | "WebDevice"
14 | ]
15 |
16 |
17 | class WebDevice(Device):
18 | _url_to_play: typing.Optional[str] = None
19 | _device_name: str
20 | _remote_token: int
21 | _devices: typing.Dict[int, 'WebDevice']
22 | _manipulation_timestamp: float
23 |
24 | def __init__(self, device_name: str, token: int, devices: typing.Dict[int, 'WebDevice']):
25 | self._device_name = device_name
26 | self._remote_token = token
27 | self._devices = devices
28 | self._manipulation_timestamp = time.time()
29 |
30 | async def stop(self):
31 | self._url_to_play = None
32 |
33 | async def on_close(self, local_token: int):
34 | self._devices.pop(self._remote_token, None)
35 |
36 | async def play(self, url: str, title: str, local_token: int):
37 | self._url_to_play = url
38 |
39 | def manipulate_timestamp(self) -> float:
40 | old = self._manipulation_timestamp
41 | self._manipulation_timestamp = time.time()
42 | return old
43 |
44 | def get_token(self) -> int:
45 | return self._remote_token
46 |
47 | def get_device_name(self) -> str:
48 | return self._device_name
49 |
50 | def get_url_to_play(self) -> typing.Optional[str]:
51 | tmp = self._url_to_play
52 | self._url_to_play = None
53 | return tmp
54 |
55 | def get_player_functions(self) -> typing.List[DevicePlayerFunction]:
56 | return []
57 |
58 |
59 | class WebDeviceApiRequestRegisterDevice(RequestHandler):
60 | _config: Config
61 | _devices: typing.Dict[int, WebDevice]
62 |
63 | def __init__(self, config: Config, devices: typing.Dict[int, WebDevice]):
64 | self._config = config
65 | self._devices = devices
66 |
67 | def get_path(self) -> str:
68 | return "/web/api/register/{password}"
69 |
70 | def get_method(self) -> str:
71 | return "GET"
72 |
73 | async def handle(self, request: Request) -> Response:
74 | password = request.match_info["password"]
75 |
76 | if password != self._config.web_ui_password:
77 | return Response(status=403)
78 |
79 | remote_token = secret_token()
80 | self._devices[remote_token] = WebDevice(f"web @({request.remote})", remote_token, self._devices)
81 | return Response(status=200, body=str(remote_token))
82 |
83 |
84 | class WebDeviceApiRequestPoll(RequestHandler):
85 | _config: Config
86 | _devices: typing.Dict[int, WebDevice]
87 |
88 | def __init__(self, config: Config, devices: typing.Dict[int, WebDevice]):
89 | self._devices = devices
90 | self._config = config
91 |
92 | def get_path(self) -> str:
93 | return "/web/api/poll/{remote_token}"
94 |
95 | def get_method(self) -> str:
96 | return "GET"
97 |
98 | async def handle(self, request: Request) -> Response:
99 | try:
100 | remote_token = int(request.match_info["remote_token"])
101 | except ValueError:
102 | return Response(status=400)
103 |
104 | try:
105 | device = self._devices[remote_token]
106 | except KeyError:
107 | return Response(status=404)
108 |
109 | device.manipulate_timestamp()
110 | url_to_play = device.get_url_to_play()
111 |
112 | if url_to_play is None:
113 | return Response(status=302)
114 |
115 | return Response(status=200, body=url_to_play)
116 |
117 |
118 | class WebDeviceFinder(DeviceFinder):
119 | _devices: typing.Dict[int, WebDevice]
120 |
121 | def __init__(self):
122 | self._devices = {}
123 |
124 | async def find(self, config: Config) -> typing.List[Device]:
125 | devices = list(self._devices.values())
126 | min_timestamp = time.time() - config.device_request_timeout
127 |
128 | for device in devices:
129 | if device.manipulate_timestamp() < min_timestamp:
130 | self._devices.pop(device.get_token(), None)
131 |
132 | return list(self._devices.values())
133 |
134 | @staticmethod
135 | def is_enabled(config: Config) -> bool:
136 | return config.web_ui_enabled
137 |
138 | async def get_routers(self, config: Config) -> RoutersDefType:
139 | return [
140 | WebDeviceApiRequestRegisterDevice(config, self._devices),
141 | WebDeviceApiRequestPoll(config, self._devices)
142 | ]
143 |
--------------------------------------------------------------------------------
/smart_tv_telegram/mtproto.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import functools
3 | import logging
4 | import os
5 | import pickle
6 | import typing
7 |
8 | import pyrogram
9 |
10 | from async_lru import alru_cache
11 | from pyrogram.handlers.handler import Handler
12 | from pyrogram.raw.functions.auth import ExportAuthorization, ImportAuthorization
13 | from pyrogram.raw.functions.help import GetConfig
14 | from pyrogram.raw.functions.messages import GetMessages
15 | from pyrogram.raw.functions.upload import GetFile
16 | from pyrogram.raw.types import InputMessageID, Message, InputDocumentFileLocation
17 | from pyrogram.errors import FloodWait
18 | import pyrogram.session
19 | from pyrogram.raw.types.upload import File
20 |
21 | from . import Config
22 |
23 | __all__ = [
24 | "Mtproto"
25 | ]
26 |
27 |
28 | class Mtproto:
29 | _config: Config
30 | _client: pyrogram.Client
31 |
32 | def __init__(self, config: Config):
33 | self._config = config
34 | self._client = pyrogram.Client(config.session_name, config.api_id, config.api_hash,
35 | bot_token=config.token, sleep_threshold=0, workdir=os.getcwd())
36 |
37 | def register(self, handler: Handler):
38 | self._client.add_handler(handler)
39 |
40 | async def reply_message(self, message_id: int, chat_id: int, text: str):
41 | await self._client.send_message(
42 | chat_id,
43 | text=text,
44 | parse_mode="html",
45 | reply_to_message_id=message_id
46 | )
47 |
48 | @alru_cache()
49 | async def get_message(self, message_id: int) -> Message:
50 | messages = await self._client.invoke(GetMessages(id=[InputMessageID(id=message_id)]))
51 |
52 | if not messages.messages:
53 | raise ValueError("wrong message_id")
54 |
55 | message = messages.messages[0]
56 |
57 | if not isinstance(message, Message):
58 | raise ValueError(f"expected `Message`, found: `{type(message).__name__}`")
59 |
60 | return message
61 |
62 | async def health_check(self):
63 | if not all(x.is_started.is_set() for x in self._client.media_sessions.values()):
64 | logging.log(logging.ERROR, "media session not connected")
65 | raise ConnectionError()
66 |
67 | if not self._client.session.is_started.is_set():
68 | logging.log(logging.ERROR, "main session not connected")
69 | raise ConnectionError()
70 |
71 | async def get_block(self, message: Message, offset: int, block_size: int) -> bytes:
72 | session = self._client.media_sessions.get(message.media.document.dc_id)
73 |
74 | request = GetFile(
75 | offset=offset,
76 | limit=block_size,
77 | location=InputDocumentFileLocation(
78 | id=message.media.document.id,
79 | access_hash=message.media.document.access_hash,
80 | file_reference=message.media.document.file_reference,
81 | thumb_size=""
82 | )
83 | )
84 |
85 | result: typing.Optional[File] = None
86 |
87 | while not isinstance(result, File):
88 | try:
89 | result = await session.invoke(request, sleep_threshold=0)
90 | except FloodWait: # file floodwait is fake
91 | await asyncio.sleep(self._config.file_fake_fw_wait)
92 |
93 | return result.bytes
94 |
95 | async def start(self):
96 | await self._client.start()
97 |
98 | config = await self._client.invoke(GetConfig())
99 | dc_ids = set(x.id for x in config.dc_options)
100 | keys_path = self._config.session_name + ".keys"
101 |
102 | if os.path.exists(keys_path):
103 | keys = pickle.load(open(keys_path, "rb"))
104 | else:
105 | keys = {}
106 |
107 | for dc_id in dc_ids:
108 | session = functools.partial(pyrogram.session.Session, self._client, dc_id, is_media=True, test_mode=False)
109 |
110 | if dc_id != await self._client.storage.dc_id():
111 | if dc_id not in keys:
112 | exported_auth = await self._client.invoke(ExportAuthorization(dc_id=dc_id))
113 |
114 | auth = pyrogram.session.Auth(self._client, dc_id, False)
115 | auth_key = await auth.create()
116 |
117 | session = session(auth_key)
118 | await session.start()
119 |
120 | await session.invoke(ImportAuthorization(id=exported_auth.id, bytes=exported_auth.bytes))
121 | keys[dc_id] = session.auth_key
122 |
123 | else:
124 | session = session(keys[dc_id])
125 | await session.start()
126 |
127 | else:
128 | session = session(await self._client.storage.auth_key())
129 | await session.start()
130 |
131 | self._client.media_sessions[dc_id] = session
132 |
133 | pickle.dump(keys, open(keys_path, "wb"))
134 |
--------------------------------------------------------------------------------
/smart_tv_telegram/devices/xbmc_device.py:
--------------------------------------------------------------------------------
1 | # from: https://github.com/home-assistant/core/blob/dev/homeassistant/components/kodi/media_player.py
2 |
3 | import asyncio
4 | import json
5 | import logging
6 | import typing
7 | import uuid
8 |
9 | import aiohttp
10 |
11 | from . import Device, DeviceFinder, RoutersDefType, DevicePlayerFunction
12 | from .. import Config
13 |
14 | __all__ = [
15 | "XbmcDevice",
16 | "XbmcDeviceFinder"
17 | ]
18 |
19 | MethodCallArgType = typing.Union[str, int, bool]
20 |
21 | _LOGGER = logging.getLogger(__name__)
22 |
23 | _JSON_HEADERS = {"content-type": "application/json"}
24 | _JSONRPC_VERSION = "2.0"
25 |
26 | _ATTR_JSONRPC = "jsonrpc"
27 | _ATTR_METHOD = "method"
28 | _ATTR_PARAMS = "params"
29 | _ATTR_ID = "id"
30 |
31 |
32 | class XbmcDeviceParams:
33 | _host: str
34 | _port: int
35 | _username: typing.Optional[str] = None
36 | _password: typing.Optional[str] = None
37 |
38 | def __init__(self, params: typing.Dict[str, str]):
39 | self._host = params["host"]
40 | self._port = params["port"]
41 |
42 | if "username" in params:
43 | self._username = params["username"]
44 | self._password = params["password"]
45 |
46 | @property
47 | def host(self) -> str:
48 | return self._host
49 |
50 | @property
51 | def port(self) -> int:
52 | return self._port
53 |
54 | @property
55 | def username(self) -> typing.Optional[str]:
56 | return self._username
57 |
58 | @property
59 | def password(self) -> typing.Optional[str]:
60 | return self._password
61 |
62 |
63 | class XbmcDevice(Device):
64 | _auth: typing.Optional[aiohttp.BasicAuth]
65 | _http_url: str
66 | _host: str
67 |
68 | def __init__(self, device: XbmcDeviceParams):
69 | if device.username:
70 | self._auth = aiohttp.BasicAuth(device.username, device.password)
71 | else:
72 | self._auth = None
73 |
74 | self._http_url = f"http://{device.host}:{device.port}/jsonrpc"
75 | self._host = device.host
76 |
77 | def get_device_name(self) -> str:
78 | return f"xbmc @{self._host}"
79 |
80 | async def on_close(self, local_token: int):
81 | pass
82 |
83 | async def _call(self, method: str, **args: typing.Union[MethodCallArgType, typing.Mapping[str, MethodCallArgType]]):
84 | data = {
85 | _ATTR_JSONRPC: _JSONRPC_VERSION,
86 | _ATTR_METHOD: method,
87 | _ATTR_ID: str(uuid.uuid4()),
88 | _ATTR_PARAMS: args
89 | }
90 |
91 | response = None
92 | session = aiohttp.ClientSession(auth=self._auth, headers=_JSON_HEADERS)
93 |
94 | try:
95 | response = await session.post(self._http_url, data=json.dumps(data))
96 |
97 | if response.status == 401:
98 | _LOGGER.error(
99 | "Error fetching Kodi data. HTTP %d Unauthorized. "
100 | "Password is incorrect.", response.status)
101 | return None
102 |
103 | if response.status != 200:
104 | _LOGGER.error(
105 | "Error fetching Kodi data. HTTP %d", response.status)
106 | return None
107 |
108 | response_json = await response.json()
109 |
110 | if "error" in response_json:
111 | _LOGGER.error(
112 | "RPC Error Code %d: %s",
113 | response_json["error"]["code"],
114 | response_json["error"]["message"])
115 | return None
116 |
117 | return response_json["result"]
118 |
119 | except (aiohttp.ClientError,
120 | asyncio.TimeoutError,
121 | ConnectionRefusedError):
122 | return None
123 |
124 | finally:
125 | if response:
126 | response.close()
127 |
128 | await session.close()
129 |
130 | async def stop(self):
131 | players = await self._call("Player.GetActivePlayers")
132 |
133 | if players:
134 | await self._call("Player.Stop", playerid=players[0]["playerid"])
135 |
136 | async def play(self, url: str, title: str, local_token: int):
137 | await self._call("Playlist.Clear", playlistid=0)
138 | await self._call("Playlist.Add", playlistid=0, item={"file": url})
139 | await self._call("Player.Open", item={"playlistid": 0}, options={"repeat": "one"})
140 |
141 | def get_player_functions(self) -> typing.List[DevicePlayerFunction]:
142 | return []
143 |
144 |
145 | class XbmcDeviceFinder(DeviceFinder):
146 | async def find(self, config: Config) -> typing.List[Device]:
147 | return [
148 | XbmcDevice(XbmcDeviceParams(params))
149 | for params in config.xbmc_devices
150 | ]
151 |
152 | @staticmethod
153 | def is_enabled(config: Config) -> bool:
154 | return config.xbmc_enabled
155 |
156 | async def get_routers(self, config: Config) -> RoutersDefType:
157 | return []
158 |
--------------------------------------------------------------------------------
/smart_tv_telegram/config.py:
--------------------------------------------------------------------------------
1 | import ast
2 | import configparser
3 | import typing
4 |
5 | __all__ = [
6 | "Config"
7 | ]
8 |
9 |
10 | class Config:
11 | _api_id: int
12 | _api_hash: str
13 | _token: str
14 | _session_name: str
15 | _file_fake_fw_wait: float
16 |
17 | _device_request_timeout: int
18 |
19 | _listen_host: str
20 | _listen_port: int
21 |
22 | _upnp_enabled: bool
23 | _upnp_scan_timeout: int = 0
24 |
25 | _chromecast_enabled: bool
26 | _chromecast_scan_timeout: int = 0
27 |
28 | _web_ui_enabled: bool
29 | _web_ui_password: str = ""
30 |
31 | _xbmc_enabled: bool
32 | _xbmc_devices: typing.List[dict]
33 |
34 | _vlc_enabled: bool
35 | _vlc_devices: typing.List[dict]
36 |
37 | _request_gone_timeout: int
38 |
39 | _admins: typing.List[int]
40 | _block_size: int
41 |
42 | def __init__(self, path: str):
43 | config = configparser.ConfigParser()
44 | config.read(path)
45 |
46 | self._api_id = int(config["mtproto"]["api_id"])
47 | self._api_hash = str(config["mtproto"]["api_hash"])
48 | self._token = str(config["mtproto"]["token"])
49 | self._session_name = str(config["mtproto"]["session_name"])
50 | self._file_fake_fw_wait = float(config["mtproto"]["file_fake_fw_wait"])
51 |
52 | self._listen_port = int(config["http"]["listen_port"])
53 | self._listen_host = str(config["http"]["listen_host"])
54 |
55 | self._request_gone_timeout = int(config["bot"]["request_gone_timeout"])
56 | self._device_request_timeout = int(config["discovery"]["device_request_timeout"])
57 |
58 | self._upnp_enabled = bool(int(config["discovery"]["upnp_enabled"]))
59 |
60 | if self._upnp_enabled:
61 | self._upnp_scan_timeout = int(config["discovery"]["upnp_scan_timeout"])
62 |
63 | if self.upnp_scan_timeout > self.device_request_timeout:
64 | raise ValueError("upnp_scan_timeout should < device_request_timeout")
65 |
66 | self._web_ui_enabled = bool(int(config["web_ui"]["enabled"]))
67 |
68 | if self._web_ui_enabled:
69 | self._web_ui_password = config["web_ui"]["password"]
70 |
71 | self._chromecast_enabled = bool(int(config["discovery"]["chromecast_enabled"]))
72 |
73 | self._xbmc_enabled = bool(int(config["discovery"]["xbmc_enabled"]))
74 |
75 | if self._xbmc_enabled:
76 | self._xbmc_devices = ast.literal_eval(config["discovery"]["xbmc_devices"])
77 |
78 | if not isinstance(self._xbmc_devices, list):
79 | raise ValueError("xbmc_devices should be a list")
80 |
81 | if not all(isinstance(x, dict) for x in self._xbmc_devices):
82 | raise ValueError("xbmc_devices should contain only dict")
83 |
84 | else:
85 | self._xbmc_devices = []
86 |
87 | self._vlc_enabled = bool(int(config["discovery"]["vlc_enabled"]))
88 |
89 | if self._vlc_enabled:
90 | self._vlc_devices = ast.literal_eval(config["discovery"]["vlc_devices"])
91 |
92 | if not isinstance(self._xbmc_devices, list):
93 | raise ValueError("vlc_devices should be a list")
94 |
95 | if not all(isinstance(x, dict) for x in self._xbmc_devices):
96 | raise ValueError("vlc_devices should contain only dict")
97 |
98 | else:
99 | self._vlc_devices = []
100 |
101 | if self._chromecast_enabled:
102 | self._chromecast_scan_timeout = int(config["discovery"]["chromecast_scan_timeout"])
103 |
104 | if self.chromecast_scan_timeout > self.device_request_timeout:
105 | raise ValueError("chromecast_scan_timeout should < device_request_timeout")
106 |
107 | self._admins = ast.literal_eval(config["bot"]["admins"])
108 | self._block_size = int(config["bot"]["block_size"])
109 |
110 | if not isinstance(self._admins, list):
111 | raise ValueError("admins should be a list")
112 |
113 | if not all(isinstance(x, int) for x in self._admins):
114 | raise ValueError("admins list should contain only integers")
115 |
116 | @property
117 | def web_ui_enabled(self) -> bool:
118 | return self._web_ui_enabled
119 |
120 | @property
121 | def web_ui_password(self) -> str:
122 | return self._web_ui_password
123 |
124 | @property
125 | def request_gone_timeout(self) -> int:
126 | return self._request_gone_timeout
127 |
128 | @property
129 | def file_fake_fw_wait(self) -> float:
130 | return self._file_fake_fw_wait
131 |
132 | @property
133 | def api_id(self) -> int:
134 | return self._api_id
135 |
136 | @property
137 | def api_hash(self) -> str:
138 | return self._api_hash
139 |
140 | @property
141 | def token(self) -> str:
142 | return self._token
143 |
144 | @property
145 | def session_name(self) -> str:
146 | return self._session_name
147 |
148 | @property
149 | def listen_host(self) -> str:
150 | return self._listen_host
151 |
152 | @property
153 | def listen_port(self) -> int:
154 | return self._listen_port
155 |
156 | @property
157 | def upnp_enabled(self) -> bool:
158 | return self._upnp_enabled
159 |
160 | @property
161 | def upnp_scan_timeout(self) -> int:
162 | return self._upnp_scan_timeout
163 |
164 | @property
165 | def chromecast_enabled(self) -> bool:
166 | return self._chromecast_enabled
167 |
168 | @property
169 | def chromecast_scan_timeout(self) -> int:
170 | return self._chromecast_scan_timeout
171 |
172 | @property
173 | def xbmc_enabled(self) -> bool:
174 | return self._xbmc_enabled
175 |
176 | @property
177 | def xbmc_devices(self) -> typing.List[dict]:
178 | return self._xbmc_devices
179 |
180 | @property
181 | def vlc_enabled(self) -> bool:
182 | return self._vlc_enabled
183 |
184 | @property
185 | def vlc_devices(self) -> typing.List[dict]:
186 | return self._vlc_devices
187 |
188 | @property
189 | def admins(self) -> typing.List[int]:
190 | return self._admins
191 |
192 | @property
193 | def block_size(self) -> int:
194 | return self._block_size
195 |
196 | @property
197 | def device_request_timeout(self) -> int:
198 | return self._device_request_timeout
199 |
--------------------------------------------------------------------------------
/smart_tv_telegram/http_server.py:
--------------------------------------------------------------------------------
1 | import abc
2 | import asyncio
3 | import os.path
4 | import typing
5 | from urllib.parse import quote
6 |
7 | from aiohttp import web
8 | from aiohttp.web_request import Request
9 | from aiohttp.web_response import Response, StreamResponse
10 | from pyrogram.raw.types import MessageMediaDocument, Document
11 | from pyrogram.utils import get_peer_id
12 |
13 | from . import Config, Mtproto, DeviceFinderCollection
14 | from .tools import parse_http_range, mtproto_filename, serialize_token, AsyncDebounce
15 |
16 | __all__ = [
17 | "Http",
18 | "OnStreamClosed"
19 | ]
20 |
21 |
22 | class OnStreamClosed(abc.ABC):
23 | @abc.abstractmethod
24 | async def handle(self, remains: float, chat_id: int, message_id: int, local_token: int):
25 | raise NotImplementedError
26 |
27 |
28 | class Http:
29 | _mtproto: Mtproto
30 | _config: Config
31 | _finders: DeviceFinderCollection
32 | _on_stream_closed: typing.Optional[OnStreamClosed] = None
33 |
34 | _tokens: typing.Set[int]
35 | _downloaded_blocks: typing.Dict[int, typing.Set[int]]
36 | _stream_debounce: typing.Dict[int, AsyncDebounce]
37 | _stream_transports: typing.Dict[int, typing.Set[asyncio.Transport]]
38 |
39 | def __init__(self, mtproto: Mtproto, config: Config, finders: DeviceFinderCollection):
40 | self._mtproto = mtproto
41 | self._config = config
42 | self._finders = finders
43 |
44 | self._tokens = set()
45 | self._downloaded_blocks = {}
46 | self._stream_debounce = {}
47 | self._stream_transports = {}
48 |
49 | def set_on_stream_closed_handler(self, handler: OnStreamClosed):
50 | self._on_stream_closed = handler
51 |
52 | async def start(self):
53 | app = web.Application()
54 | app.router.add_static("/static/", os.path.dirname(__file__) + "/static/")
55 | app.router.add_get("/stream/{message_id}/{token}", self._stream_handler)
56 | app.router.add_options("/stream/{message_id}/{token}", self._upnp_discovery_handler)
57 | app.router.add_put("/stream/{message_id}/{token}", self._upnp_discovery_handler)
58 | app.router.add_get("/healthcheck", self._health_check_handler)
59 |
60 | for finder in self._finders.get_finders(self._config):
61 | routers = await finder.get_routers(self._config)
62 |
63 | for handler in routers:
64 | app.router.add_route(handler.get_method(), handler.get_path(), handler.handle)
65 |
66 | # noinspection PyProtectedMember
67 | await web._run_app(app, host=self._config.listen_host, port=self._config.listen_port)
68 |
69 | def add_remote_token(self, message_id: int, partial_remote_token: int) -> int:
70 | local_token = serialize_token(message_id, partial_remote_token)
71 | self._tokens.add(local_token)
72 | return local_token
73 |
74 | def _check_local_token(self, local_token: int) -> bool:
75 | return local_token in self._tokens
76 |
77 | @staticmethod
78 | def _write_http_range_headers(result: StreamResponse, read_after: int, size: int, max_size: int):
79 | result.headers.setdefault("Content-Range", f"bytes {read_after}-{max_size}/{size}")
80 | result.headers.setdefault("Accept-Ranges", "bytes")
81 | result.headers.setdefault("Content-Length", str(size))
82 |
83 | @staticmethod
84 | def _write_access_control_headers(result: StreamResponse):
85 | result.headers.setdefault("Content-Type", "video/mp4")
86 | result.headers.setdefault("Access-Control-Allow-Origin", "*")
87 | result.headers.setdefault("Access-Control-Allow-Methods", "GET, OPTIONS")
88 | result.headers.setdefault("Access-Control-Allow-Headers", "Content-Type")
89 | result.headers.setdefault("transferMode.dlna.org", "Streaming")
90 | result.headers.setdefault("TimeSeekRange.dlna.org", "npt=0.00-")
91 | result.headers.setdefault("contentFeatures.dlna.org", "DLNA.ORG_OP=01;DLNA.ORG_CI=0;")
92 |
93 | @staticmethod
94 | def _write_filename_header(result: StreamResponse, filename: str):
95 | result.headers.setdefault("Content-Disposition", f'inline; filename="{quote(filename)}"')
96 |
97 | async def _health_check_handler(self, _: Request) -> typing.Optional[Response]:
98 | try:
99 | await self._mtproto.health_check()
100 | return Response(status=200, text="ok")
101 | except ConnectionError:
102 | return Response(status=500, text="gone")
103 |
104 | async def _upnp_discovery_handler(self, _: Request) -> typing.Optional[Response]:
105 | result = Response(status=200)
106 | self._write_access_control_headers(result)
107 | return result
108 |
109 | def _feed_timeout(self, message_id: int, chat_id: int, local_token: int, size: int):
110 | debounce = self._stream_debounce.setdefault(
111 | local_token,
112 | AsyncDebounce(self._timeout_handler, self._config.request_gone_timeout)
113 | )
114 |
115 | debounce.update_args(message_id, chat_id, local_token, size)
116 |
117 | def _feed_downloaded_blocks(self, block_id: int, local_token: int):
118 | downloaded_blocks = self._downloaded_blocks.setdefault(local_token, set())
119 | downloaded_blocks.add(block_id)
120 |
121 | def _feed_stream_transport(self, local_token: int, transport: asyncio.Transport):
122 | transports = self._stream_transports.setdefault(local_token, set())
123 | transports.add(transport)
124 |
125 | def _get_stream_transports(self, local_token: int) -> typing.Set[asyncio.Transport]:
126 | return self._stream_transports[local_token] if local_token in self._stream_transports else set()
127 |
128 | async def _timeout_handler(self, message_id: int, chat_id: int, local_token: int, size: int):
129 | _debounce: typing.Optional[AsyncDebounce] = None # avoid garbage collector
130 |
131 | if all(t.is_closing() for t in self._get_stream_transports(local_token)):
132 | blocks = (size // self._config.block_size) + 1
133 |
134 | if local_token in self._tokens:
135 | self._tokens.remove(local_token)
136 |
137 | remain_blocks = blocks
138 |
139 | if local_token in self._downloaded_blocks:
140 | remain_blocks = blocks - len(self._downloaded_blocks[local_token])
141 | del self._downloaded_blocks[local_token]
142 |
143 | if local_token in self._stream_debounce:
144 | _debounce = self._stream_debounce[local_token]
145 | del self._stream_debounce[local_token]
146 |
147 | if local_token in self._stream_transports:
148 | del self._stream_transports[local_token]
149 |
150 | remain_blocks_perceptual = remain_blocks / blocks * 100
151 | on_stream_closed = self._on_stream_closed
152 |
153 | if isinstance(on_stream_closed, OnStreamClosed):
154 | await on_stream_closed.handle(remain_blocks_perceptual, chat_id, message_id, local_token)
155 |
156 | if local_token in self._stream_debounce:
157 | self._stream_debounce[local_token].reschedule()
158 |
159 | del _debounce
160 |
161 | async def _stream_handler(self, request: Request) -> typing.Optional[Response]:
162 | _message_id: str = request.match_info["message_id"]
163 |
164 | if not _message_id.isdigit():
165 | return Response(status=401)
166 |
167 | _token: str = request.match_info["token"]
168 |
169 | if not _token.isdigit():
170 | return Response(status=401)
171 |
172 | token = int(_token)
173 | del _token
174 | message_id = int(_message_id)
175 | del _message_id
176 |
177 | local_token = serialize_token(message_id, token)
178 |
179 | if not self._check_local_token(local_token):
180 | return Response(status=403)
181 |
182 | range_header = request.headers.get("Range")
183 |
184 | if range_header is None:
185 | offset = 0
186 | data_to_skip = False
187 | max_size = None
188 |
189 | else:
190 | try:
191 | offset, data_to_skip, max_size = parse_http_range(range_header, self._config.block_size)
192 | except ValueError:
193 | return Response(status=400)
194 |
195 | if data_to_skip > self._config.block_size:
196 | return Response(status=500)
197 |
198 | try:
199 | message = await self._mtproto.get_message(int(message_id))
200 | except ValueError:
201 | return Response(status=404)
202 |
203 | if not isinstance(message.media, MessageMediaDocument):
204 | return Response(status=404)
205 |
206 | if not isinstance(message.media.document, Document):
207 | return Response(status=404)
208 |
209 | size = message.media.document.size
210 | read_after = offset + data_to_skip
211 |
212 | if read_after > size:
213 | return Response(status=400)
214 |
215 | if (max_size is not None) and (size < max_size):
216 | return Response(status=400)
217 |
218 | if max_size is None:
219 | max_size = size
220 |
221 | stream = StreamResponse(status=206 if (read_after or (max_size != size)) else 200)
222 | self._write_http_range_headers(stream, read_after, size, max_size)
223 |
224 | try:
225 | filename = mtproto_filename(message)
226 | except TypeError:
227 | filename = f"file_{message.media.document.id}"
228 |
229 | self._write_filename_header(stream, filename)
230 | self._write_access_control_headers(stream)
231 |
232 | await stream.prepare(request)
233 |
234 | while offset < max_size:
235 | self._feed_timeout(message_id, get_peer_id(message.peer_id), local_token, size)
236 | block = await self._mtproto.get_block(message, offset, self._config.block_size)
237 | new_offset = offset + len(block)
238 |
239 | if data_to_skip:
240 | block = block[data_to_skip:]
241 | data_to_skip = False
242 |
243 | if new_offset > max_size:
244 | block = block[:-(new_offset - max_size)]
245 |
246 | if request.transport is None:
247 | break
248 |
249 | self._feed_stream_transport(local_token, request.transport)
250 |
251 | if request.transport.is_closing():
252 | break
253 |
254 | await stream.write(block)
255 | self._feed_downloaded_blocks(offset, local_token)
256 | offset = new_offset
257 |
258 | await stream.write_eof()
259 | stream.force_close()
260 |
261 | return stream
262 |
--------------------------------------------------------------------------------
/smart_tv_telegram/bot.py:
--------------------------------------------------------------------------------
1 | import abc
2 | import asyncio
3 | import enum
4 | import functools
5 | import html
6 | import sys
7 | import traceback
8 | import typing
9 |
10 | if sys.version_info >= (3, 11):
11 | from asyncio import timeout
12 | else:
13 | from async_timeout import timeout
14 |
15 | from pyrogram import Client, filters
16 | from pyrogram.filters import create
17 | from pyrogram.handlers import MessageHandler, CallbackQueryHandler
18 | from pyrogram.types import ReplyKeyboardRemove, Message, KeyboardButton, ReplyKeyboardMarkup, CallbackQuery, \
19 | InlineKeyboardMarkup, InlineKeyboardButton
20 |
21 | from . import Config, Mtproto, Http, OnStreamClosed, DeviceFinderCollection
22 | from .devices import Device, DevicePlayerFunction
23 | from .tools import build_uri, pyrogram_filename, secret_token
24 |
25 | __all__ = [
26 | "Bot"
27 | ]
28 |
29 | _REMOVE_KEYBOARD = ReplyKeyboardRemove()
30 | _CANCEL_BUTTON = "^Cancel"
31 |
32 |
33 | class States(enum.Enum):
34 | NOTHING = enum.auto()
35 | SELECT = enum.auto()
36 |
37 |
38 | class StateData(abc.ABC):
39 | @abc.abstractmethod
40 | def get_associated_state(self) -> States:
41 | raise NotImplementedError
42 |
43 |
44 | class SelectStateData(StateData):
45 | msg_id: int
46 | filename: str
47 | devices: typing.List[Device]
48 |
49 | def get_associated_state(self) -> States:
50 | return States.SELECT
51 |
52 | def __init__(self, msg_id: int, filename: str, devices: typing.List[Device]):
53 | self.msg_id = msg_id
54 | self.filename = filename
55 | self.devices = devices
56 |
57 |
58 | class OnStreamClosedHandler(OnStreamClosed):
59 | _mtproto: Mtproto
60 | _functions: typing.Dict[int, typing.Any]
61 | _devices: typing.Dict[int, Device]
62 |
63 | def __init__(self,
64 | mtproto: Mtproto,
65 | functions: typing.Dict[int, typing.Any],
66 | devices: typing.Dict[int, Device]):
67 | self._mtproto = mtproto
68 | self._functions = functions
69 | self._devices = devices
70 |
71 | async def handle(self, remains: float, chat_id: int, message_id: int, local_token: int):
72 | if local_token in self._functions:
73 | del self._functions[local_token]
74 |
75 | on_close: typing.Optional[typing.Callable[[int], typing.Coroutine]] = None
76 |
77 | if local_token in self._devices:
78 | device = self._devices[local_token]
79 | del self._devices[local_token]
80 | on_close = device.on_close
81 |
82 | await self._mtproto.reply_message(message_id, chat_id, f"download closed, {remains:0.2f}% remains")
83 |
84 | if on_close is not None:
85 | await on_close(local_token)
86 |
87 |
88 | class TelegramStateMachine:
89 | _states: typing.Dict[int, typing.Tuple[States, typing.Union[bool, StateData]]]
90 |
91 | def __init__(self):
92 | self._states = {}
93 |
94 | def get_state(self, message: Message) -> typing.Tuple[States, typing.Union[bool, StateData]]:
95 | user_id = message.from_user.id
96 |
97 | if user_id in self._states:
98 | return self._states[user_id]
99 |
100 | return States.NOTHING, False
101 |
102 | def set_state(self, message: Message, state: States, data: typing.Union[bool, StateData]) -> bool:
103 | if isinstance(data, bool) or data.get_associated_state() == state:
104 | self._states[message.from_user.id] = (state, data)
105 | return True
106 |
107 | raise TypeError()
108 |
109 |
110 | class Bot:
111 | _config: Config
112 | _state_machine: TelegramStateMachine
113 | _mtproto: Mtproto
114 | _http: Http
115 | _finders: DeviceFinderCollection
116 | _functions: typing.Dict[int, typing.Dict[int, DevicePlayerFunction]]
117 | _devices: typing.Dict[int, Device]
118 |
119 | def __init__(self, mtproto: Mtproto, config: Config, http: Http, finders: DeviceFinderCollection):
120 | self._config = config
121 | self._mtproto = mtproto
122 | self._http = http
123 | self._finders = finders
124 | self._state_machine = TelegramStateMachine()
125 | self._functions = {}
126 | self._devices = {}
127 |
128 | def get_on_stream_closed(self) -> OnStreamClosed:
129 | return OnStreamClosedHandler(self._mtproto, self._functions, self._devices)
130 |
131 | def prepare(self):
132 | admin_filter = filters.chat(self._config.admins) & filters.private
133 | self._mtproto.register(MessageHandler(self._new_document, filters.document & admin_filter))
134 | self._mtproto.register(MessageHandler(self._new_document, filters.video & admin_filter))
135 | self._mtproto.register(MessageHandler(self._new_document, filters.audio & admin_filter))
136 | self._mtproto.register(MessageHandler(self._new_document, filters.animation & admin_filter))
137 | self._mtproto.register(MessageHandler(self._new_document, filters.voice & admin_filter))
138 | self._mtproto.register(MessageHandler(self._new_document, filters.video_note & admin_filter))
139 |
140 | admin_filter_inline = create(lambda _, __, m: m.from_user.id in self._config.admins)
141 | self._mtproto.register(CallbackQueryHandler(self._device_player_function, admin_filter_inline))
142 |
143 | state_filter = create(lambda _, __, m: self._state_machine.get_state(m)[0] == States.SELECT)
144 | self._mtproto.register(MessageHandler(self._select_device, filters.text & admin_filter & state_filter))
145 |
146 | async def _device_player_function(self, _: Client, message: CallbackQuery):
147 | data = message.data
148 |
149 | try:
150 | data = int(data)
151 | except ValueError:
152 | await message.answer("wrong callback")
153 |
154 | try:
155 | device_function = next(
156 | f_v
157 | for f in self._functions.values()
158 | for f_k, f_v in f.items()
159 | if f_k == data
160 | )
161 | except StopIteration:
162 | await message.answer("stream closed")
163 | return
164 |
165 | if not await device_function.is_enabled(self._config):
166 | await message.answer("function not enabled")
167 | return
168 |
169 | async with timeout(self._config.device_request_timeout) as timeout_context:
170 | await device_function.handle()
171 |
172 | if timeout_context.expired():
173 | await message.answer("request timeout")
174 | else:
175 | await message.answer("done")
176 |
177 | async def _select_device(self, _: Client, message: Message):
178 | data: SelectStateData
179 | _, data = self._state_machine.get_state(message)
180 |
181 | self._state_machine.set_state(message, States.NOTHING, False)
182 | reply = functools.partial(message.reply, reply_markup=_REMOVE_KEYBOARD)
183 |
184 | if message.text == _CANCEL_BUTTON:
185 | await reply("Cancelled")
186 | return
187 |
188 | try:
189 | device = next(
190 | device
191 | for device in data.devices
192 | if repr(device) == message.text
193 | )
194 | except StopIteration:
195 | await reply("Wrong device")
196 | return
197 |
198 | async with timeout(self._config.device_request_timeout) as timeout_context:
199 | token = secret_token()
200 | local_token = self._http.add_remote_token(data.msg_id, token)
201 | uri = build_uri(self._config, data.msg_id, token)
202 |
203 | # noinspection PyBroadException
204 | try:
205 | await device.stop()
206 | await device.play(uri, data.filename, local_token)
207 |
208 | except Exception as ex:
209 | traceback.print_exc()
210 |
211 | await reply(
212 | "Error while communicate with the device:\n\n"
213 | f"{html.escape(str(ex))}"
214 | )
215 |
216 | else:
217 | self._devices[local_token] = device
218 | physical_functions = device.get_player_functions()
219 | functions = self._functions[local_token] = {}
220 |
221 | if physical_functions:
222 | buttons = []
223 |
224 | for function in physical_functions:
225 | function_id = secret_token()
226 | function_name = await function.get_name()
227 | button = InlineKeyboardButton(function_name, str(function_id))
228 | functions[function_id] = function
229 | buttons.append([button])
230 |
231 | await message.reply(
232 | f"Device {html.escape(device.get_device_name())}\n"
233 | f"controller for file {data.msg_id}",
234 | reply_markup=InlineKeyboardMarkup(buttons)
235 | )
236 |
237 | stub_message = await reply("stub")
238 | await stub_message.delete()
239 |
240 | else:
241 | await reply(f"Playing file {data.msg_id}")
242 |
243 | if timeout_context.expired():
244 | await reply("Timeout while communicate with the device")
245 |
246 | async def _new_document(self, _: Client, message: Message):
247 | self._state_machine.set_state(message, States.NOTHING, False)
248 |
249 | devices = []
250 |
251 | for finder in self._finders.get_finders(self._config):
252 | try:
253 | async with timeout(self._config.device_request_timeout + 1):
254 | devices.extend(await finder.find(self._config))
255 | except asyncio.CancelledError:
256 | pass
257 |
258 | if devices:
259 | try:
260 | filename = pyrogram_filename(message)
261 | except TypeError:
262 | filename = "None"
263 |
264 | self._state_machine.set_state(
265 | message,
266 | States.SELECT,
267 | SelectStateData(
268 | message.id,
269 | str(filename),
270 | devices.copy()
271 | )
272 | )
273 |
274 | buttons = [[KeyboardButton(repr(device))] for device in devices]
275 | buttons.append([KeyboardButton(_CANCEL_BUTTON)])
276 | markup = ReplyKeyboardMarkup(buttons, one_time_keyboard=True)
277 | await message.reply("Select a device", reply_markup=markup)
278 |
279 | else:
280 | await message.reply("Supported devices not found in the network", reply_markup=_REMOVE_KEYBOARD)
281 |
--------------------------------------------------------------------------------
/smart_tv_telegram/devices/upnp_device.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import enum
3 | import html
4 | import io
5 | import typing
6 | import xml.etree
7 | import xml.etree.ElementTree
8 | from xml.sax.saxutils import escape
9 |
10 | from aiohttp.web_request import Request
11 | from aiohttp.web_response import Response
12 | from async_upnp_client.aiohttp import AiohttpRequester
13 | from async_upnp_client.client import UpnpService, UpnpDevice as UpnpServiceDevice
14 | from async_upnp_client.client_factory import UpnpFactory
15 | from async_upnp_client.event_handler import UpnpEventHandler
16 | from async_upnp_client.exceptions import UpnpError
17 | from async_upnp_client.search import async_search
18 |
19 | from . import Device, DeviceFinder, RoutersDefType, DevicePlayerFunction, RequestHandler
20 | from .. import Config
21 | from ..tools import ascii_only, base_url
22 |
23 | __all__ = [
24 | "UpnpDevice",
25 | "UpnpDeviceFinder"
26 | ]
27 |
28 | _AVTRANSPORT_SCHEMA = "urn:schemas-upnp-org:service:AVTransport:1"
29 |
30 | _VIDEO_FLAGS = "21700000000000000000000000000000"
31 |
32 | _DLL_METADATA = """
33 |
37 |
38 | {title}
39 | object.item.videoItem.movie
40 |
41 | SA_RINCON65031_
42 |
43 | {url}
44 |
45 |
46 | """
47 |
48 | _STATUS_TAG = "{urn:schemas-upnp-org:metadata-1-0/AVT/}TransportStatus"
49 |
50 |
51 | async def _upnp_safe_stop(service: UpnpService):
52 | stop = service.action("Stop")
53 |
54 | try:
55 | await stop.async_call(InstanceID=0)
56 | except UpnpError as error:
57 | normalized_error = str(error).lower()
58 |
59 | if "transition not available" in normalized_error:
60 | return
61 |
62 | if "action stop failed" in normalized_error:
63 | return
64 |
65 | raise error
66 |
67 |
68 | class UpnpReconnectFunction(DevicePlayerFunction):
69 | _service: UpnpService
70 |
71 | def __init__(self, service: UpnpService):
72 | self._service = service
73 |
74 | async def handle(self):
75 | play = self._service.action("Play")
76 | await play.async_call(InstanceID=0, Speed="1")
77 |
78 | async def get_name(self) -> str:
79 | raise NotImplementedError
80 |
81 | async def is_enabled(self, config: Config):
82 | raise NotImplementedError
83 |
84 |
85 | class UpnpPlayerStatus(enum.Enum):
86 | PLAYING = enum.auto()
87 | ERROR = enum.auto()
88 | NOTHING = enum.auto()
89 | STOPPED = enum.auto()
90 |
91 |
92 | def _player_status(data: bytes) -> UpnpPlayerStatus:
93 | event: xml.etree.ElementTree.Element
94 | decoded = html.unescape(data.decode("utf8"))
95 |
96 | stream = io.StringIO()
97 | stream.write(decoded)
98 | stream.seek(0)
99 |
100 | parser = xml.etree.ElementTree.iterparse(stream)
101 | reach_ok = False
102 |
103 | for _, event in parser:
104 | if event.tag == _STATUS_TAG:
105 | status = event.get("val")
106 |
107 | if status == "OK":
108 | reach_ok = True
109 |
110 | if status == "STOPPED":
111 | return UpnpPlayerStatus.STOPPED
112 |
113 | if status == "ERROR_OCCURRED":
114 | return UpnpPlayerStatus.ERROR
115 |
116 | if reach_ok:
117 | return UpnpPlayerStatus.PLAYING
118 |
119 | return UpnpPlayerStatus.NOTHING
120 |
121 |
122 | class DeviceStatus:
123 | reconnect: UpnpReconnectFunction
124 | playing: bool
125 | errored: bool
126 |
127 | def __init__(self, reconnect: UpnpReconnectFunction, playing: bool = False, errored: bool = False):
128 | self.reconnect = reconnect
129 | self.playing = playing
130 | self.errored = errored
131 |
132 |
133 | class UpnpNotifyServer(RequestHandler):
134 | _devices: typing.Dict[int, DeviceStatus]
135 |
136 | def __init__(self):
137 | self._devices = {}
138 |
139 | def add_device(self, device: DeviceStatus, local_token: int):
140 | self._devices[local_token] = device
141 |
142 | def remove_device(self, local_token: int):
143 | if local_token in self._devices:
144 | del self._devices[local_token]
145 |
146 | def get_path(self) -> str:
147 | return "/upnp/notify/{local_token}"
148 |
149 | def get_method(self) -> str:
150 | return "NOTIFY"
151 |
152 | async def handle(self, request: Request) -> Response:
153 | local_token_raw: str = request.match_info["local_token"]
154 |
155 | if not local_token_raw.isdigit():
156 | return Response(status=400)
157 |
158 | local_token: int = int(local_token_raw)
159 |
160 | if local_token not in self._devices:
161 | return Response(status=403)
162 |
163 | device = self._devices[local_token]
164 | data = await request.read()
165 | status = _player_status(data)
166 |
167 | if status == UpnpPlayerStatus.PLAYING:
168 | device.playing = True
169 |
170 | if status == UpnpPlayerStatus.ERROR and device.playing:
171 | device.errored = True
172 |
173 | if device.errored and status == UpnpPlayerStatus.NOTHING:
174 | device.errored = False
175 | device.playing = False
176 | await device.reconnect.handle()
177 |
178 | return Response(status=200)
179 |
180 |
181 | class UpnpPlayFunction(DevicePlayerFunction):
182 | _service: UpnpService
183 |
184 | def __init__(self, service: UpnpService):
185 | self._service = service
186 |
187 | async def get_name(self) -> str:
188 | return "PLAY"
189 |
190 | async def handle(self):
191 | play = self._service.action("Play")
192 | await play.async_call(InstanceID=0, Speed="1")
193 |
194 | async def is_enabled(self, config: Config):
195 | return config.upnp_enabled
196 |
197 |
198 | class UpnpPauseFunction(DevicePlayerFunction):
199 | _service: UpnpService
200 |
201 | def __init__(self, service: UpnpService):
202 | self._service = service
203 |
204 | async def get_name(self) -> str:
205 | return "PAUSE"
206 |
207 | async def handle(self):
208 | play = self._service.action("Pause")
209 | await play.async_call(InstanceID=0)
210 |
211 | async def is_enabled(self, config: Config):
212 | return config.upnp_enabled
213 |
214 |
215 | class SubscribeTask:
216 | _service: UpnpService
217 | _task: typing.Optional[asyncio.Task]
218 | _event_handler: UpnpEventHandler
219 |
220 | def __init__(self,
221 | device: UpnpServiceDevice,
222 | service: UpnpService,
223 | url: str):
224 | self._service = service
225 | self._task = None
226 | self._event_handler = UpnpEventHandler(url, device.requester)
227 |
228 | async def start(self):
229 | await self.close()
230 | self._task = asyncio.create_task(self._loop())
231 |
232 | async def close(self):
233 | task = self._task
234 | self._task = None
235 |
236 | if task is not None:
237 | task.cancel()
238 | await self._event_handler.async_unsubscribe_all()
239 |
240 | async def _loop(self):
241 | await self._event_handler.async_subscribe(self._service)
242 |
243 | while True:
244 | await asyncio.sleep(10)
245 | # async_resubscribe_all NOT WORK ON SAMSUNG TV
246 | await self._event_handler.async_unsubscribe_all()
247 | await self._event_handler.async_subscribe(self._service)
248 |
249 |
250 | class UpnpDevice(Device):
251 | _device: UpnpServiceDevice
252 | _service: UpnpService
253 | _config: Config
254 | _subscribe_task: typing.Optional[SubscribeTask]
255 | _notify_handler: UpnpNotifyServer
256 |
257 | def __init__(self, device: UpnpServiceDevice, config: Config, notify_handler: UpnpNotifyServer):
258 | self._device = device
259 | self._service = self._device.service(_AVTRANSPORT_SCHEMA)
260 | self._config = config
261 | self._notify_handler = notify_handler
262 | self._subscribe_task = None
263 |
264 | def get_device_name(self) -> str:
265 | return self._device.friendly_name
266 |
267 | async def stop(self):
268 | await _upnp_safe_stop(self._service)
269 |
270 | async def on_close(self, local_token: int):
271 | task = self._subscribe_task
272 |
273 | if task is not None:
274 | await task.close()
275 |
276 | self._notify_handler.remove_device(local_token)
277 |
278 | async def play(self, url: str, title: str, local_token: int):
279 | set_url = self._service.action("SetAVTransportURI")
280 | meta = _DLL_METADATA.format(title=escape(ascii_only(title)), url=escape(url), flags=_VIDEO_FLAGS)
281 | await set_url.async_call(InstanceID=0, CurrentURI=url, CurrentURIMetaData=meta)
282 |
283 | device_status = DeviceStatus(UpnpReconnectFunction(self._service))
284 | self._notify_handler.add_device(device_status, local_token)
285 |
286 | url = f"{base_url(self._config)}/upnp/notify/{local_token}"
287 | self._subscribe_task = SubscribeTask(self._device, self._service, url)
288 | await self._subscribe_task.start()
289 |
290 | play = self._service.action("Play")
291 | await play.async_call(InstanceID=0, Speed="1")
292 |
293 | def get_player_functions(self) -> typing.List[DevicePlayerFunction]:
294 | return [
295 | UpnpPlayFunction(self._service),
296 | UpnpPauseFunction(self._service)
297 | ]
298 |
299 |
300 | class UpnpDeviceFinder(DeviceFinder):
301 | _notify_handler: UpnpNotifyServer
302 |
303 | def __init__(self):
304 | self._notify_handler = UpnpNotifyServer()
305 |
306 | async def find(self, config: Config) -> typing.List[Device]:
307 | devices = []
308 | requester = AiohttpRequester()
309 | factory = UpnpFactory(requester)
310 |
311 | async def on_response(data: typing.Mapping[str, typing.Any]) -> None:
312 | devices.append(await factory.async_create_device(data.get("LOCATION")))
313 |
314 | await async_search(search_target=_AVTRANSPORT_SCHEMA,
315 | timeout=config.upnp_scan_timeout,
316 | async_callback=on_response)
317 |
318 | return [UpnpDevice(device, config, self._notify_handler) for device in devices]
319 |
320 | @staticmethod
321 | def is_enabled(config: Config) -> bool:
322 | return config.upnp_enabled
323 |
324 | async def get_routers(self, config: Config) -> RoutersDefType:
325 | return [self._notify_handler]
326 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU AFFERO GENERAL PUBLIC LICENSE
2 | Version 3, 19 November 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU Affero General Public License is a free, copyleft license for
11 | software and other kinds of works, specifically designed to ensure
12 | cooperation with the community in the case of network server software.
13 |
14 | The licenses for most software and other practical works are designed
15 | to take away your freedom to share and change the works. By contrast,
16 | our General Public Licenses are intended to guarantee your freedom to
17 | share and change all versions of a program--to make sure it remains free
18 | software for all its users.
19 |
20 | When we speak of free software, we are referring to freedom, not
21 | price. Our General Public Licenses are designed to make sure that you
22 | have the freedom to distribute copies of free software (and charge for
23 | them if you wish), that you receive source code or can get it if you
24 | want it, that you can change the software or use pieces of it in new
25 | free programs, and that you know you can do these things.
26 |
27 | Developers that use our General Public Licenses protect your rights
28 | with two steps: (1) assert copyright on the software, and (2) offer
29 | you this License which gives you legal permission to copy, distribute
30 | and/or modify the software.
31 |
32 | A secondary benefit of defending all users' freedom is that
33 | improvements made in alternate versions of the program, if they
34 | receive widespread use, become available for other developers to
35 | incorporate. Many developers of free software are heartened and
36 | encouraged by the resulting cooperation. However, in the case of
37 | software used on network servers, this result may fail to come about.
38 | The GNU General Public License permits making a modified version and
39 | letting the public access it on a server without ever releasing its
40 | source code to the public.
41 |
42 | The GNU Affero General Public License is designed specifically to
43 | ensure that, in such cases, the modified source code becomes available
44 | to the community. It requires the operator of a network server to
45 | provide the source code of the modified version running there to the
46 | users of that server. Therefore, public use of a modified version, on
47 | a publicly accessible server, gives the public access to the source
48 | code of the modified version.
49 |
50 | An older license, called the Affero General Public License and
51 | published by Affero, was designed to accomplish similar goals. This is
52 | a different license, not a version of the Affero GPL, but Affero has
53 | released a new version of the Affero GPL which permits relicensing under
54 | this license.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | TERMS AND CONDITIONS
60 |
61 | 0. Definitions.
62 |
63 | "This License" refers to version 3 of the GNU Affero General Public License.
64 |
65 | "Copyright" also means copyright-like laws that apply to other kinds of
66 | works, such as semiconductor masks.
67 |
68 | "The Program" refers to any copyrightable work licensed under this
69 | License. Each licensee is addressed as "you". "Licensees" and
70 | "recipients" may be individuals or organizations.
71 |
72 | To "modify" a work means to copy from or adapt all or part of the work
73 | in a fashion requiring copyright permission, other than the making of an
74 | exact copy. The resulting work is called a "modified version" of the
75 | earlier work or a work "based on" the earlier work.
76 |
77 | A "covered work" means either the unmodified Program or a work based
78 | on the Program.
79 |
80 | To "propagate" a work means to do anything with it that, without
81 | permission, would make you directly or secondarily liable for
82 | infringement under applicable copyright law, except executing it on a
83 | computer or modifying a private copy. Propagation includes copying,
84 | distribution (with or without modification), making available to the
85 | public, and in some countries other activities as well.
86 |
87 | To "convey" a work means any kind of propagation that enables other
88 | parties to make or receive copies. Mere interaction with a user through
89 | a computer network, with no transfer of a copy, is not conveying.
90 |
91 | An interactive user interface displays "Appropriate Legal Notices"
92 | to the extent that it includes a convenient and prominently visible
93 | feature that (1) displays an appropriate copyright notice, and (2)
94 | tells the user that there is no warranty for the work (except to the
95 | extent that warranties are provided), that licensees may convey the
96 | work under this License, and how to view a copy of this License. If
97 | the interface presents a list of user commands or options, such as a
98 | menu, a prominent item in the list meets this criterion.
99 |
100 | 1. Source Code.
101 |
102 | The "source code" for a work means the preferred form of the work
103 | for making modifications to it. "Object code" means any non-source
104 | form of a work.
105 |
106 | A "Standard Interface" means an interface that either is an official
107 | standard defined by a recognized standards body, or, in the case of
108 | interfaces specified for a particular programming language, one that
109 | is widely used among developers working in that language.
110 |
111 | The "System Libraries" of an executable work include anything, other
112 | than the work as a whole, that (a) is included in the normal form of
113 | packaging a Major Component, but which is not part of that Major
114 | Component, and (b) serves only to enable use of the work with that
115 | Major Component, or to implement a Standard Interface for which an
116 | implementation is available to the public in source code form. A
117 | "Major Component", in this context, means a major essential component
118 | (kernel, window system, and so on) of the specific operating system
119 | (if any) on which the executable work runs, or a compiler used to
120 | produce the work, or an object code interpreter used to run it.
121 |
122 | The "Corresponding Source" for a work in object code form means all
123 | the source code needed to generate, install, and (for an executable
124 | work) run the object code and to modify the work, including scripts to
125 | control those activities. However, it does not include the work's
126 | System Libraries, or general-purpose tools or generally available free
127 | programs which are used unmodified in performing those activities but
128 | which are not part of the work. For example, Corresponding Source
129 | includes interface definition files associated with source files for
130 | the work, and the source code for shared libraries and dynamically
131 | linked subprograms that the work is specifically designed to require,
132 | such as by intimate data communication or control flow between those
133 | subprograms and other parts of the work.
134 |
135 | The Corresponding Source need not include anything that users
136 | can regenerate automatically from other parts of the Corresponding
137 | Source.
138 |
139 | The Corresponding Source for a work in source code form is that
140 | same work.
141 |
142 | 2. Basic Permissions.
143 |
144 | All rights granted under this License are granted for the term of
145 | copyright on the Program, and are irrevocable provided the stated
146 | conditions are met. This License explicitly affirms your unlimited
147 | permission to run the unmodified Program. The output from running a
148 | covered work is covered by this License only if the output, given its
149 | content, constitutes a covered work. This License acknowledges your
150 | rights of fair use or other equivalent, as provided by copyright law.
151 |
152 | You may make, run and propagate covered works that you do not
153 | convey, without conditions so long as your license otherwise remains
154 | in force. You may convey covered works to others for the sole purpose
155 | of having them make modifications exclusively for you, or provide you
156 | with facilities for running those works, provided that you comply with
157 | the terms of this License in conveying all material for which you do
158 | not control copyright. Those thus making or running the covered works
159 | for you must do so exclusively on your behalf, under your direction
160 | and control, on terms that prohibit them from making any copies of
161 | your copyrighted material outside their relationship with you.
162 |
163 | Conveying under any other circumstances is permitted solely under
164 | the conditions stated below. Sublicensing is not allowed; section 10
165 | makes it unnecessary.
166 |
167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168 |
169 | No covered work shall be deemed part of an effective technological
170 | measure under any applicable law fulfilling obligations under article
171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172 | similar laws prohibiting or restricting circumvention of such
173 | measures.
174 |
175 | When you convey a covered work, you waive any legal power to forbid
176 | circumvention of technological measures to the extent such circumvention
177 | is effected by exercising rights under this License with respect to
178 | the covered work, and you disclaim any intention to limit operation or
179 | modification of the work as a means of enforcing, against the work's
180 | users, your or third parties' legal rights to forbid circumvention of
181 | technological measures.
182 |
183 | 4. Conveying Verbatim Copies.
184 |
185 | You may convey verbatim copies of the Program's source code as you
186 | receive it, in any medium, provided that you conspicuously and
187 | appropriately publish on each copy an appropriate copyright notice;
188 | keep intact all notices stating that this License and any
189 | non-permissive terms added in accord with section 7 apply to the code;
190 | keep intact all notices of the absence of any warranty; and give all
191 | recipients a copy of this License along with the Program.
192 |
193 | You may charge any price or no price for each copy that you convey,
194 | and you may offer support or warranty protection for a fee.
195 |
196 | 5. Conveying Modified Source Versions.
197 |
198 | You may convey a work based on the Program, or the modifications to
199 | produce it from the Program, in the form of source code under the
200 | terms of section 4, provided that you also meet all of these conditions:
201 |
202 | a) The work must carry prominent notices stating that you modified
203 | it, and giving a relevant date.
204 |
205 | b) The work must carry prominent notices stating that it is
206 | released under this License and any conditions added under section
207 | 7. This requirement modifies the requirement in section 4 to
208 | "keep intact all notices".
209 |
210 | c) You must license the entire work, as a whole, under this
211 | License to anyone who comes into possession of a copy. This
212 | License will therefore apply, along with any applicable section 7
213 | additional terms, to the whole of the work, and all its parts,
214 | regardless of how they are packaged. This License gives no
215 | permission to license the work in any other way, but it does not
216 | invalidate such permission if you have separately received it.
217 |
218 | d) If the work has interactive user interfaces, each must display
219 | Appropriate Legal Notices; however, if the Program has interactive
220 | interfaces that do not display Appropriate Legal Notices, your
221 | work need not make them do so.
222 |
223 | A compilation of a covered work with other separate and independent
224 | works, which are not by their nature extensions of the covered work,
225 | and which are not combined with it such as to form a larger program,
226 | in or on a volume of a storage or distribution medium, is called an
227 | "aggregate" if the compilation and its resulting copyright are not
228 | used to limit the access or legal rights of the compilation's users
229 | beyond what the individual works permit. Inclusion of a covered work
230 | in an aggregate does not cause this License to apply to the other
231 | parts of the aggregate.
232 |
233 | 6. Conveying Non-Source Forms.
234 |
235 | You may convey a covered work in object code form under the terms
236 | of sections 4 and 5, provided that you also convey the
237 | machine-readable Corresponding Source under the terms of this License,
238 | in one of these ways:
239 |
240 | a) Convey the object code in, or embodied in, a physical product
241 | (including a physical distribution medium), accompanied by the
242 | Corresponding Source fixed on a durable physical medium
243 | customarily used for software interchange.
244 |
245 | b) Convey the object code in, or embodied in, a physical product
246 | (including a physical distribution medium), accompanied by a
247 | written offer, valid for at least three years and valid for as
248 | long as you offer spare parts or customer support for that product
249 | model, to give anyone who possesses the object code either (1) a
250 | copy of the Corresponding Source for all the software in the
251 | product that is covered by this License, on a durable physical
252 | medium customarily used for software interchange, for a price no
253 | more than your reasonable cost of physically performing this
254 | conveying of source, or (2) access to copy the
255 | Corresponding Source from a network server at no charge.
256 |
257 | c) Convey individual copies of the object code with a copy of the
258 | written offer to provide the Corresponding Source. This
259 | alternative is allowed only occasionally and noncommercially, and
260 | only if you received the object code with such an offer, in accord
261 | with subsection 6b.
262 |
263 | d) Convey the object code by offering access from a designated
264 | place (gratis or for a charge), and offer equivalent access to the
265 | Corresponding Source in the same way through the same place at no
266 | further charge. You need not require recipients to copy the
267 | Corresponding Source along with the object code. If the place to
268 | copy the object code is a network server, the Corresponding Source
269 | may be on a different server (operated by you or a third party)
270 | that supports equivalent copying facilities, provided you maintain
271 | clear directions next to the object code saying where to find the
272 | Corresponding Source. Regardless of what server hosts the
273 | Corresponding Source, you remain obligated to ensure that it is
274 | available for as long as needed to satisfy these requirements.
275 |
276 | e) Convey the object code using peer-to-peer transmission, provided
277 | you inform other peers where the object code and Corresponding
278 | Source of the work are being offered to the general public at no
279 | charge under subsection 6d.
280 |
281 | A separable portion of the object code, whose source code is excluded
282 | from the Corresponding Source as a System Library, need not be
283 | included in conveying the object code work.
284 |
285 | A "User Product" is either (1) a "consumer product", which means any
286 | tangible personal property which is normally used for personal, family,
287 | or household purposes, or (2) anything designed or sold for incorporation
288 | into a dwelling. In determining whether a product is a consumer product,
289 | doubtful cases shall be resolved in favor of coverage. For a particular
290 | product received by a particular user, "normally used" refers to a
291 | typical or common use of that class of product, regardless of the status
292 | of the particular user or of the way in which the particular user
293 | actually uses, or expects or is expected to use, the product. A product
294 | is a consumer product regardless of whether the product has substantial
295 | commercial, industrial or non-consumer uses, unless such uses represent
296 | the only significant mode of use of the product.
297 |
298 | "Installation Information" for a User Product means any methods,
299 | procedures, authorization keys, or other information required to install
300 | and execute modified versions of a covered work in that User Product from
301 | a modified version of its Corresponding Source. The information must
302 | suffice to ensure that the continued functioning of the modified object
303 | code is in no case prevented or interfered with solely because
304 | modification has been made.
305 |
306 | If you convey an object code work under this section in, or with, or
307 | specifically for use in, a User Product, and the conveying occurs as
308 | part of a transaction in which the right of possession and use of the
309 | User Product is transferred to the recipient in perpetuity or for a
310 | fixed term (regardless of how the transaction is characterized), the
311 | Corresponding Source conveyed under this section must be accompanied
312 | by the Installation Information. But this requirement does not apply
313 | if neither you nor any third party retains the ability to install
314 | modified object code on the User Product (for example, the work has
315 | been installed in ROM).
316 |
317 | The requirement to provide Installation Information does not include a
318 | requirement to continue to provide support service, warranty, or updates
319 | for a work that has been modified or installed by the recipient, or for
320 | the User Product in which it has been modified or installed. Access to a
321 | network may be denied when the modification itself materially and
322 | adversely affects the operation of the network or violates the rules and
323 | protocols for communication across the network.
324 |
325 | Corresponding Source conveyed, and Installation Information provided,
326 | in accord with this section must be in a format that is publicly
327 | documented (and with an implementation available to the public in
328 | source code form), and must require no special password or key for
329 | unpacking, reading or copying.
330 |
331 | 7. Additional Terms.
332 |
333 | "Additional permissions" are terms that supplement the terms of this
334 | License by making exceptions from one or more of its conditions.
335 | Additional permissions that are applicable to the entire Program shall
336 | be treated as though they were included in this License, to the extent
337 | that they are valid under applicable law. If additional permissions
338 | apply only to part of the Program, that part may be used separately
339 | under those permissions, but the entire Program remains governed by
340 | this License without regard to the additional permissions.
341 |
342 | When you convey a copy of a covered work, you may at your option
343 | remove any additional permissions from that copy, or from any part of
344 | it. (Additional permissions may be written to require their own
345 | removal in certain cases when you modify the work.) You may place
346 | additional permissions on material, added by you to a covered work,
347 | for which you have or can give appropriate copyright permission.
348 |
349 | Notwithstanding any other provision of this License, for material you
350 | add to a covered work, you may (if authorized by the copyright holders of
351 | that material) supplement the terms of this License with terms:
352 |
353 | a) Disclaiming warranty or limiting liability differently from the
354 | terms of sections 15 and 16 of this License; or
355 |
356 | b) Requiring preservation of specified reasonable legal notices or
357 | author attributions in that material or in the Appropriate Legal
358 | Notices displayed by works containing it; or
359 |
360 | c) Prohibiting misrepresentation of the origin of that material, or
361 | requiring that modified versions of such material be marked in
362 | reasonable ways as different from the original version; or
363 |
364 | d) Limiting the use for publicity purposes of names of licensors or
365 | authors of the material; or
366 |
367 | e) Declining to grant rights under trademark law for use of some
368 | trade names, trademarks, or service marks; or
369 |
370 | f) Requiring indemnification of licensors and authors of that
371 | material by anyone who conveys the material (or modified versions of
372 | it) with contractual assumptions of liability to the recipient, for
373 | any liability that these contractual assumptions directly impose on
374 | those licensors and authors.
375 |
376 | All other non-permissive additional terms are considered "further
377 | restrictions" within the meaning of section 10. If the Program as you
378 | received it, or any part of it, contains a notice stating that it is
379 | governed by this License along with a term that is a further
380 | restriction, you may remove that term. If a license document contains
381 | a further restriction but permits relicensing or conveying under this
382 | License, you may add to a covered work material governed by the terms
383 | of that license document, provided that the further restriction does
384 | not survive such relicensing or conveying.
385 |
386 | If you add terms to a covered work in accord with this section, you
387 | must place, in the relevant source files, a statement of the
388 | additional terms that apply to those files, or a notice indicating
389 | where to find the applicable terms.
390 |
391 | Additional terms, permissive or non-permissive, may be stated in the
392 | form of a separately written license, or stated as exceptions;
393 | the above requirements apply either way.
394 |
395 | 8. Termination.
396 |
397 | You may not propagate or modify a covered work except as expressly
398 | provided under this License. Any attempt otherwise to propagate or
399 | modify it is void, and will automatically terminate your rights under
400 | this License (including any patent licenses granted under the third
401 | paragraph of section 11).
402 |
403 | However, if you cease all violation of this License, then your
404 | license from a particular copyright holder is reinstated (a)
405 | provisionally, unless and until the copyright holder explicitly and
406 | finally terminates your license, and (b) permanently, if the copyright
407 | holder fails to notify you of the violation by some reasonable means
408 | prior to 60 days after the cessation.
409 |
410 | Moreover, your license from a particular copyright holder is
411 | reinstated permanently if the copyright holder notifies you of the
412 | violation by some reasonable means, this is the first time you have
413 | received notice of violation of this License (for any work) from that
414 | copyright holder, and you cure the violation prior to 30 days after
415 | your receipt of the notice.
416 |
417 | Termination of your rights under this section does not terminate the
418 | licenses of parties who have received copies or rights from you under
419 | this License. If your rights have been terminated and not permanently
420 | reinstated, you do not qualify to receive new licenses for the same
421 | material under section 10.
422 |
423 | 9. Acceptance Not Required for Having Copies.
424 |
425 | You are not required to accept this License in order to receive or
426 | run a copy of the Program. Ancillary propagation of a covered work
427 | occurring solely as a consequence of using peer-to-peer transmission
428 | to receive a copy likewise does not require acceptance. However,
429 | nothing other than this License grants you permission to propagate or
430 | modify any covered work. These actions infringe copyright if you do
431 | not accept this License. Therefore, by modifying or propagating a
432 | covered work, you indicate your acceptance of this License to do so.
433 |
434 | 10. Automatic Licensing of Downstream Recipients.
435 |
436 | Each time you convey a covered work, the recipient automatically
437 | receives a license from the original licensors, to run, modify and
438 | propagate that work, subject to this License. You are not responsible
439 | for enforcing compliance by third parties with this License.
440 |
441 | An "entity transaction" is a transaction transferring control of an
442 | organization, or substantially all assets of one, or subdividing an
443 | organization, or merging organizations. If propagation of a covered
444 | work results from an entity transaction, each party to that
445 | transaction who receives a copy of the work also receives whatever
446 | licenses to the work the party's predecessor in interest had or could
447 | give under the previous paragraph, plus a right to possession of the
448 | Corresponding Source of the work from the predecessor in interest, if
449 | the predecessor has it or can get it with reasonable efforts.
450 |
451 | You may not impose any further restrictions on the exercise of the
452 | rights granted or affirmed under this License. For example, you may
453 | not impose a license fee, royalty, or other charge for exercise of
454 | rights granted under this License, and you may not initiate litigation
455 | (including a cross-claim or counterclaim in a lawsuit) alleging that
456 | any patent claim is infringed by making, using, selling, offering for
457 | sale, or importing the Program or any portion of it.
458 |
459 | 11. Patents.
460 |
461 | A "contributor" is a copyright holder who authorizes use under this
462 | License of the Program or a work on which the Program is based. The
463 | work thus licensed is called the contributor's "contributor version".
464 |
465 | A contributor's "essential patent claims" are all patent claims
466 | owned or controlled by the contributor, whether already acquired or
467 | hereafter acquired, that would be infringed by some manner, permitted
468 | by this License, of making, using, or selling its contributor version,
469 | but do not include claims that would be infringed only as a
470 | consequence of further modification of the contributor version. For
471 | purposes of this definition, "control" includes the right to grant
472 | patent sublicenses in a manner consistent with the requirements of
473 | this License.
474 |
475 | Each contributor grants you a non-exclusive, worldwide, royalty-free
476 | patent license under the contributor's essential patent claims, to
477 | make, use, sell, offer for sale, import and otherwise run, modify and
478 | propagate the contents of its contributor version.
479 |
480 | In the following three paragraphs, a "patent license" is any express
481 | agreement or commitment, however denominated, not to enforce a patent
482 | (such as an express permission to practice a patent or covenant not to
483 | sue for patent infringement). To "grant" such a patent license to a
484 | party means to make such an agreement or commitment not to enforce a
485 | patent against the party.
486 |
487 | If you convey a covered work, knowingly relying on a patent license,
488 | and the Corresponding Source of the work is not available for anyone
489 | to copy, free of charge and under the terms of this License, through a
490 | publicly available network server or other readily accessible means,
491 | then you must either (1) cause the Corresponding Source to be so
492 | available, or (2) arrange to deprive yourself of the benefit of the
493 | patent license for this particular work, or (3) arrange, in a manner
494 | consistent with the requirements of this License, to extend the patent
495 | license to downstream recipients. "Knowingly relying" means you have
496 | actual knowledge that, but for the patent license, your conveying the
497 | covered work in a country, or your recipient's use of the covered work
498 | in a country, would infringe one or more identifiable patents in that
499 | country that you have reason to believe are valid.
500 |
501 | If, pursuant to or in connection with a single transaction or
502 | arrangement, you convey, or propagate by procuring conveyance of, a
503 | covered work, and grant a patent license to some of the parties
504 | receiving the covered work authorizing them to use, propagate, modify
505 | or convey a specific copy of the covered work, then the patent license
506 | you grant is automatically extended to all recipients of the covered
507 | work and works based on it.
508 |
509 | A patent license is "discriminatory" if it does not include within
510 | the scope of its coverage, prohibits the exercise of, or is
511 | conditioned on the non-exercise of one or more of the rights that are
512 | specifically granted under this License. You may not convey a covered
513 | work if you are a party to an arrangement with a third party that is
514 | in the business of distributing software, under which you make payment
515 | to the third party based on the extent of your activity of conveying
516 | the work, and under which the third party grants, to any of the
517 | parties who would receive the covered work from you, a discriminatory
518 | patent license (a) in connection with copies of the covered work
519 | conveyed by you (or copies made from those copies), or (b) primarily
520 | for and in connection with specific products or compilations that
521 | contain the covered work, unless you entered into that arrangement,
522 | or that patent license was granted, prior to 28 March 2007.
523 |
524 | Nothing in this License shall be construed as excluding or limiting
525 | any implied license or other defenses to infringement that may
526 | otherwise be available to you under applicable patent law.
527 |
528 | 12. No Surrender of Others' Freedom.
529 |
530 | If conditions are imposed on you (whether by court order, agreement or
531 | otherwise) that contradict the conditions of this License, they do not
532 | excuse you from the conditions of this License. If you cannot convey a
533 | covered work so as to satisfy simultaneously your obligations under this
534 | License and any other pertinent obligations, then as a consequence you may
535 | not convey it at all. For example, if you agree to terms that obligate you
536 | to collect a royalty for further conveying from those to whom you convey
537 | the Program, the only way you could satisfy both those terms and this
538 | License would be to refrain entirely from conveying the Program.
539 |
540 | 13. Remote Network Interaction; Use with the GNU General Public License.
541 |
542 | Notwithstanding any other provision of this License, if you modify the
543 | Program, your modified version must prominently offer all users
544 | interacting with it remotely through a computer network (if your version
545 | supports such interaction) an opportunity to receive the Corresponding
546 | Source of your version by providing access to the Corresponding Source
547 | from a network server at no charge, through some standard or customary
548 | means of facilitating copying of software. This Corresponding Source
549 | shall include the Corresponding Source for any work covered by version 3
550 | of the GNU General Public License that is incorporated pursuant to the
551 | following paragraph.
552 |
553 | Notwithstanding any other provision of this License, you have
554 | permission to link or combine any covered work with a work licensed
555 | under version 3 of the GNU General Public License into a single
556 | combined work, and to convey the resulting work. The terms of this
557 | License will continue to apply to the part which is the covered work,
558 | but the work with which it is combined will remain governed by version
559 | 3 of the GNU General Public License.
560 |
561 | 14. Revised Versions of this License.
562 |
563 | The Free Software Foundation may publish revised and/or new versions of
564 | the GNU Affero General Public License from time to time. Such new versions
565 | will be similar in spirit to the present version, but may differ in detail to
566 | address new problems or concerns.
567 |
568 | Each version is given a distinguishing version number. If the
569 | Program specifies that a certain numbered version of the GNU Affero General
570 | Public License "or any later version" applies to it, you have the
571 | option of following the terms and conditions either of that numbered
572 | version or of any later version published by the Free Software
573 | Foundation. If the Program does not specify a version number of the
574 | GNU Affero General Public License, you may choose any version ever published
575 | by the Free Software Foundation.
576 |
577 | If the Program specifies that a proxy can decide which future
578 | versions of the GNU Affero General Public License can be used, that proxy's
579 | public statement of acceptance of a version permanently authorizes you
580 | to choose that version for the Program.
581 |
582 | Later license versions may give you additional or different
583 | permissions. However, no additional obligations are imposed on any
584 | author or copyright holder as a result of your choosing to follow a
585 | later version.
586 |
587 | 15. Disclaimer of Warranty.
588 |
589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597 |
598 | 16. Limitation of Liability.
599 |
600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608 | SUCH DAMAGES.
609 |
610 | 17. Interpretation of Sections 15 and 16.
611 |
612 | If the disclaimer of warranty and limitation of liability provided
613 | above cannot be given local legal effect according to their terms,
614 | reviewing courts shall apply local law that most closely approximates
615 | an absolute waiver of all civil liability in connection with the
616 | Program, unless a warranty or assumption of liability accompanies a
617 | copy of the Program in return for a fee.
618 |
619 | END OF TERMS AND CONDITIONS
620 |
621 | How to Apply These Terms to Your New Programs
622 |
623 | If you develop a new program, and you want it to be of the greatest
624 | possible use to the public, the best way to achieve this is to make it
625 | free software which everyone can redistribute and change under these terms.
626 |
627 | To do so, attach the following notices to the program. It is safest
628 | to attach them to the start of each source file to most effectively
629 | state the exclusion of warranty; and each file should have at least
630 | the "copyright" line and a pointer to where the full notice is found.
631 |
632 |
633 | Copyright (C)
634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published
637 | by the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------