···66It requires to have an application created in Twitch, with a client id and a client
77secret that will be use to obtain the necessary credentials.
8899+## Install
1010+1111+The recommended way to install purple is using [pipx](https://pipx.pypa.io/latest/installation/).
1212+This could be done by using directly the repository URL:
1313+1414+```bash
1515+pipx install git+https://github.com/marcosgabarda/purple-cli
1616+```
1717+1818+This will add `purple` command to your shell:
1919+2020+```bash
2121+$ purple -h
2222+usage: purple [-h] [-v] [-V] [--popular [POPULAR]] [-l [LANG]]
2323+2424+Get the list of live streams from the list of following channels in Twitch.
2525+2626+options:
2727+ -h, --help show this help message and exit
2828+ -v, --verbose increase output verbosity
2929+ -V, --version show version
3030+ --popular [POPULAR] get the list of live streams with most viewers (top 20 by default).
3131+ -l, --lang [LANG] filter the list of live streams by language.
3232+```
3333+934## Settings
10351111-This tool requires the following environment variables to work:
3636+`purple-cli` requires uses a [Twitch application](https://dev.twitch.tv/console/apps/create),
3737+and to take from there the credentials.
3838+3939+The following environment variables can be used to set the credentials:
12401341- `PURPLE_CLIENT_ID` Twitch application client id.
1442- `PURPLE_CLIENT_SECRET` Twitch application client secret.
+29-11
src/purple/api.py
···132132 if total is None:
133133 total = data["total"]
134134135135- followed.extend(
136136- [
137137- {
138138- "name": channel["broadcaster_name"],
139139- "login": channel["broadcaster_login"],
140140- "id": channel["broadcaster_id"],
141141- "followed_at": channel["followed_at"],
142142- }
143143- for channel in data["data"]
144144- ]
145145- )
135135+ followed.extend(data["data"])
146136147137 cursor = data["pagination"].get("cursor")
148138 if cursor:
···179169 params["after"] = cursor
180170181171 return streams
172172+173173+174174+def retrieve_live_streams(
175175+ access_token: str,
176176+ language: list[str] | None = None,
177177+ size: int | None = None,
178178+) -> list[dict]:
179179+ """Retrieve the list of live stream, sorted by viewers.
180180+181181+ It doesn't relay in pagination, because the number of viewers can change between
182182+ calls, and therefore, it could generate duplicated results.
183183+ """
184184+ params: dict[str, str | int | list[str]] = {
185185+ "type": "live",
186186+ }
187187+ if language:
188188+ params["language"] = language
189189+ if size:
190190+ if size > 100:
191191+ logger.warning("Twitch API supports maximum of 100 items.")
192192+ params["first"] = size
193193+194194+ with twitch_api_client(access_token) as client:
195195+ response = client.get("/helix/streams", params=params)
196196+ raise_for_status(response)
197197+ data = response.json()
198198+199199+ return data["data"]
+73-22
src/purple/cli.py
···55import logging
66import sys
7788+from pydantic import ValidationError
99+810import purple
9111012from .api import (
1111- retrieve_followed_channels,
1213 retrieve_followed_streams,
1414+ retrieve_live_streams,
1315 retrieve_user,
1416)
1517from .auth import obtain_access_token
1818+from .settings import settings
16191720logger = logging.getLogger(__name__)
182119222020-def followed(args: argparse.Namespace) -> None:
2323+def live_followed(languages: list[str] | None = None) -> str:
2124 """List the channels the user follows."""
2222- # logger
2323- if args.verbose:
2424- logging.basicConfig(level=logging.DEBUG)
2525-2625 # obtain access token
2726 access_token = obtain_access_token()
2827···3231 user_id = user["id"]
33323433 # get followed channels
3535- followed = retrieve_followed_channels(access_token=access_token, user_id=user_id)
3434+ streams = retrieve_followed_streams(access_token=access_token, user_id=user_id)
36353737- print(json.dumps(followed, indent=2))
3636+ # filter streams by language
3737+ if languages:
3838+ streams = [stream for stream in streams if stream["language"] in languages]
38394040+ return json.dumps(streams, indent=2)
39414040-def live(args: argparse.Namespace) -> None:
4141- """List the channels the user follows."""
4242- # logger
4343- if args.verbose:
4444- logging.basicConfig(level=logging.DEBUG)
45424343+def live_popular(size: int, languages: list[str] | None = None) -> str:
4444+ """List the most popular channels."""
4645 # obtain access token
4746 access_token = obtain_access_token()
4848-4949- # get user id
5050- user = retrieve_user(access_token=access_token)
5151- logger.debug(f"User data:\n{user}")
5252- user_id = user["id"]
53475448 # get followed channels
5555- streams = retrieve_followed_streams(access_token=access_token, user_id=user_id)
4949+ streams = retrieve_live_streams(
5050+ access_token=access_token,
5151+ size=size,
5252+ language=languages,
5353+ )
56545757- print(json.dumps(streams, indent=2))
5555+ return json.dumps(streams, indent=2)
5656+5757+5858+def parse_languages(value: str | None) -> list[str] | None:
5959+ """Parse the popular argument."""
6060+ if not value or value == "all":
6161+ return None
6262+ return value.split(",")
6363+6464+6565+def do_it(args: argparse.Namespace) -> None:
6666+ """Execute the command."""
6767+ if not args.popular:
6868+ streams = live_followed(languages=args.lang)
6969+ else:
7070+ streams = live_popular(size=args.popular, languages=args.lang)
7171+ print(streams)
587259736074def main():
6175 """Execute main function to handle the CLI parameters and operations."""
6276 parser = argparse.ArgumentParser(
6363- description="Collects data from your Twitch account to use it in other scripts."
7777+ description=(
7878+ "Get the list of live streams from the list of following channels in "
7979+ "Twitch."
8080+ )
6481 )
6582 parser.add_argument(
6683 "-v",
···6986 action="store_true",
7087 )
7188 parser.add_argument("-V", "--version", help="show version", action="store_true")
7272- parser.set_defaults(func=live)
8989+ parser.add_argument(
9090+ "--popular",
9191+ type=int,
9292+ nargs="?",
9393+ const=20,
9494+ help="get the list of live streams with most viewers (top 20 by default).",
9595+ )
9696+ parser.add_argument(
9797+ "-l",
9898+ "--lang",
9999+ nargs="?",
100100+ const="all",
101101+ default="all",
102102+ type=parse_languages,
103103+ help="filter the list of live streams by language.",
104104+ )
105105+106106+ parser.set_defaults(func=do_it)
7310774108 args = parser.parse_args()
75109110110+ # debug logger
111111+ if args.verbose:
112112+ logging.basicConfig(level=logging.DEBUG)
113113+76114 # just print version
77115 if args.version:
78116 print(purple.__version__)
79117 sys.exit(0)
118118+119119+ # check settings
120120+ try:
121121+ settings.client_id
122122+ settings.client_secret
123123+ except ValidationError:
124124+ logger.error(
125125+ "\033[93mYou need to define the client ID and the client secret to execute "
126126+ "purple. This is done using the following environment variables:\033[0m\n\n"
127127+ " - \033[96mPURPLE_CLIENT_ID\033[0m\n"
128128+ " - \033[96mPURPLE_CLIENT_SECRET\033[0m"
129129+ )
130130+ sys.exit(-1)
8013181132 args.func(args)
+31
src/purple/settings.py
···33import os
44from functools import lru_cache
55from pathlib import Path
66+from typing import Any
6778from pydantic_settings import BaseSettings, SettingsConfigDict
899101111+def lazy_settings(cls: type) -> type:
1212+ """Define a decorator for use a settings instance as a lazy object.
1313+1414+ That it is only initialized when it is called the first time.
1515+ """
1616+1717+ class LazySettings:
1818+ """Wrap a Settings class, to allow on need evaluation."""
1919+2020+ _wrapped_settings_class: type[BaseSettings] = cls
2121+ _wrapped_settings: BaseSettings | None = None
2222+2323+ def __getattr__(self, name: str) -> Any:
2424+ if not self._wrapped_settings:
2525+ self._wrapped_settings = self._wrapped_settings_class()
2626+ return getattr(self._wrapped_settings, name)
2727+2828+ def __setattr__(self, name: str, value: Any) -> None:
2929+ if name in ("_wrapped_settings_class", "_wrapped_settings"):
3030+ self.__dict__[name] = value
3131+ else:
3232+ if not self._wrapped_settings:
3333+ self._wrapped_settings = self._wrapped_settings_class()
3434+ setattr(self._wrapped_settings, name, value)
3535+3636+ return LazySettings
3737+3838+1039@lru_cache
1140def app_data_path() -> Path:
1241 """Retrieve the path to the default application data path."""
···2453 return Path(data_home)
255426555656+@lazy_settings
2757class Settings(BaseSettings):
2858 """Configuration of the purple script."""
2959···4777 return app_data_path() / "auth.json"
487849798080+# Lazy instance of settings to enable on need evaluation of the settings properties
5081settings = Settings()