Implement support for campaign ACLs
This commit is contained in:
1 parent
e21494cfe8
commit
bd9d818b21
8 files changed
+333
-198
No files matched your search
Vendored
+3
-3
@@ -16,7 +16,7 @@
|
|||||||
"type": "python",
|
"type": "python",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "main.py",
|
"program": "main.py",
|
||||||
"args": ["-vv"],
|
"args": ["--debug", "-vv"],
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"justMyCode": false
|
"justMyCode": false
|
||||||
},
|
},
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"type": "python",
|
"type": "python",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "main.py",
|
"program": "main.py",
|
||||||
"args": ["-vvv"],
|
"args": ["--debug", "-vvv"],
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"justMyCode": false
|
"justMyCode": false
|
||||||
},
|
},
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"type": "python",
|
"type": "python",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "main.py",
|
"program": "main.py",
|
||||||
"args": ["-vvv", "--debug-ws"],
|
"args": ["--debug", "-vvv", "--debug-ws"],
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"justMyCode": false
|
"justMyCode": false
|
||||||
},
|
},
|
||||||
|
|||||||
+38
-23
@@ -6,9 +6,9 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Optional, TYPE_CHECKING
|
from typing import Any, Optional, SupportsInt, TYPE_CHECKING
|
||||||
|
|
||||||
from inventory import Game
|
from utils import Game
|
||||||
from exceptions import MinerException
|
from exceptions import MinerException
|
||||||
from constants import JsonType, BASE_URL, GQL_OPERATIONS, ONLINE_DELAY, DROPS_ENABLED_TAG
|
from constants import JsonType, BASE_URL, GQL_OPERATIONS, ONLINE_DELAY, DROPS_ENABLED_TAG
|
||||||
|
|
||||||
@@ -28,9 +28,7 @@ class Stream:
|
|||||||
self.viewers: int = stream["viewersCount"]
|
self.viewers: int = stream["viewersCount"]
|
||||||
self.drops_enabled: bool = any(tag["id"] == DROPS_ENABLED_TAG for tag in stream["tags"])
|
self.drops_enabled: bool = any(tag["id"] == DROPS_ENABLED_TAG for tag in stream["tags"])
|
||||||
settings = data["broadcastSettings"]
|
settings = data["broadcastSettings"]
|
||||||
self.game: Optional[Game] = None
|
self.game: Optional[Game] = Game(settings["game"]) if settings["game"] else None
|
||||||
if settings["game"] is not None:
|
|
||||||
self.game = Game(settings["game"])
|
|
||||||
self.title: str = settings["title"]
|
self.title: str = settings["title"]
|
||||||
self._timestamp = datetime.now(timezone.utc)
|
self._timestamp = datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -39,38 +37,34 @@ class Stream:
|
|||||||
self = super().__new__(cls)
|
self = super().__new__(cls)
|
||||||
self._twitch = channel._twitch
|
self._twitch = channel._twitch
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.broadcast_id = data["id"]
|
self.broadcast_id = int(data["id"])
|
||||||
self.viewers = data["viewersCount"]
|
self.viewers = data["viewersCount"]
|
||||||
self.drops_enabled = any(tag["id"] == DROPS_ENABLED_TAG for tag in data["tags"])
|
self.drops_enabled = any(tag["id"] == DROPS_ENABLED_TAG for tag in data["tags"])
|
||||||
self.game = Game(data["game"])
|
self.game = Game(data["game"]) # has to be there since we searched with it
|
||||||
self.title = data["title"]
|
self.title = data["title"]
|
||||||
self._timestamp = datetime.now(timezone.utc)
|
self._timestamp = datetime.now(timezone.utc)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
class Channel:
|
class Channel:
|
||||||
async def __new__(cls, *args, **kwargs):
|
def __init__(
|
||||||
"""
|
self, twitch: Twitch, channel_id: SupportsInt, channel_name: str, *, priority: bool = False
|
||||||
Enables __init__ to be async.
|
):
|
||||||
The instance is returned after initialization completes.
|
|
||||||
"""
|
|
||||||
self = super().__new__(cls)
|
|
||||||
await self.__init__(*args, **kwargs) # type: ignore
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __init__(self, twitch: Twitch, channel_name: str): # type: ignore
|
|
||||||
self._twitch: Twitch = twitch
|
self._twitch: Twitch = twitch
|
||||||
self.id: int = 0 # temp, to be filled by get_stream
|
self.id: int = int(channel_id)
|
||||||
self.name: str = channel_name
|
self.name: str = channel_name
|
||||||
self.url: str = f"{BASE_URL}/{channel_name}"
|
self.url: str = f"{BASE_URL}/{channel_name}"
|
||||||
self._spade_url: Optional[str] = None
|
self._spade_url: Optional[str] = None
|
||||||
self.points: Optional[int] = None
|
self.points: Optional[int] = None
|
||||||
self._stream: Optional[Stream] = None
|
self._stream: Optional[Stream] = None
|
||||||
self._pending_stream_up: Optional[asyncio.Task[Any]] = None
|
self._pending_stream_up: Optional[asyncio.Task[Any]] = None
|
||||||
await self.get_stream()
|
# Priority channels are considered first when switching channels,
|
||||||
|
# and if we're watching a non-priority channel,
|
||||||
|
# a priority channel going up triggers a switch
|
||||||
|
self.priority: bool = priority
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_directory(cls, twitch: Twitch, data: JsonType):
|
def from_directory(cls, twitch: Twitch, data: JsonType, *, priority: bool = False) -> Channel:
|
||||||
self = super().__new__(cls)
|
self = super().__new__(cls)
|
||||||
self._twitch = twitch
|
self._twitch = twitch
|
||||||
channel = data["broadcaster"]
|
channel = data["broadcaster"]
|
||||||
@@ -81,6 +75,24 @@ class Channel:
|
|||||||
self.points = None
|
self.points = None
|
||||||
self._stream = Stream.from_directory(self, data)
|
self._stream = Stream.from_directory(self, data)
|
||||||
self._pending_stream_up = None
|
self._pending_stream_up = None
|
||||||
|
self.priority = priority
|
||||||
|
return self
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def from_name(
|
||||||
|
cls, twitch: Twitch, channel_name: str, *, priority: bool = False
|
||||||
|
) -> Channel:
|
||||||
|
self = super().__new__(cls)
|
||||||
|
self._twitch = twitch
|
||||||
|
# self.id to be filled by get_stream
|
||||||
|
self.name = channel_name
|
||||||
|
self.url = f"{BASE_URL}/{channel_name}"
|
||||||
|
self._spade_url = None
|
||||||
|
self.points = None
|
||||||
|
self._stream = None
|
||||||
|
self._pending_stream_up = None
|
||||||
|
self.priority = priority
|
||||||
|
await self.get_stream()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
@@ -136,10 +148,10 @@ class Channel:
|
|||||||
self.display()
|
self.display()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def drops_enabled(self) -> Optional[bool]:
|
def drops_enabled(self) -> bool:
|
||||||
if self._stream is not None:
|
if self._stream is not None:
|
||||||
return self._stream.drops_enabled
|
return self._stream.drops_enabled
|
||||||
return None
|
return False
|
||||||
|
|
||||||
def display(self):
|
def display(self):
|
||||||
self._twitch.gui.channels.display(self)
|
self._twitch.gui.channels.display(self)
|
||||||
@@ -200,9 +212,11 @@ class Channel:
|
|||||||
"""
|
"""
|
||||||
self.display()
|
self.display()
|
||||||
await asyncio.sleep(ONLINE_DELAY.total_seconds())
|
await asyncio.sleep(ONLINE_DELAY.total_seconds())
|
||||||
await self.check_online()
|
online = await self.check_online()
|
||||||
self._pending_stream_up = None
|
self._pending_stream_up = None
|
||||||
self.display()
|
self.display()
|
||||||
|
if online:
|
||||||
|
self._twitch.on_online(self)
|
||||||
|
|
||||||
def set_online(self):
|
def set_online(self):
|
||||||
"""
|
"""
|
||||||
@@ -228,6 +242,7 @@ class Channel:
|
|||||||
if self.online:
|
if self.online:
|
||||||
self._stream = None
|
self._stream = None
|
||||||
self.display()
|
self.display()
|
||||||
|
self._twitch.on_offline(self)
|
||||||
|
|
||||||
async def claim_bonus(self):
|
async def claim_bonus(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
+1
-1
@@ -48,13 +48,13 @@ FORMATTER = logging.Formatter(
|
|||||||
|
|
||||||
class State(Enum):
|
class State(Enum):
|
||||||
IDLE = auto()
|
IDLE = auto()
|
||||||
EXIT = auto()
|
|
||||||
INVENTORY_FETCH = auto()
|
INVENTORY_FETCH = auto()
|
||||||
GAMES_UPDATE = auto()
|
GAMES_UPDATE = auto()
|
||||||
GAME_SELECT = auto()
|
GAME_SELECT = auto()
|
||||||
CHANNELS_FETCH = auto()
|
CHANNELS_FETCH = auto()
|
||||||
CHANNELS_CLEANUP = auto()
|
CHANNELS_CLEANUP = auto()
|
||||||
CHANNEL_SWITCH = auto()
|
CHANNEL_SWITCH = auto()
|
||||||
|
EXIT = auto()
|
||||||
|
|
||||||
|
|
||||||
class GQLOperation(JsonType):
|
class GQLOperation(JsonType):
|
||||||
|
|||||||
+25
-40
@@ -2,42 +2,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from typing import Optional, List, Dict, Set, TYPE_CHECKING
|
from typing import Optional, List, Dict, Set, Iterable, TYPE_CHECKING
|
||||||
|
|
||||||
|
from channel import Channel
|
||||||
|
from utils import invalidate_cache, Game
|
||||||
from constants import JsonType, GQL_OPERATIONS
|
from constants import JsonType, GQL_OPERATIONS
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from twitch import Twitch
|
from twitch import Twitch
|
||||||
|
|
||||||
|
|
||||||
def _invalidate_cache(instance, *attrnames):
|
|
||||||
for name in attrnames:
|
|
||||||
try:
|
|
||||||
delattr(instance, name)
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Game:
|
|
||||||
def __init__(self, data: JsonType):
|
|
||||||
self.id: int = int(data["id"])
|
|
||||||
self.name: str = data["name"]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"Game({self.id}, {self.name})"
|
|
||||||
|
|
||||||
def __eq__(self, other: object):
|
|
||||||
if isinstance(other, self.__class__):
|
|
||||||
return self.id == other.id
|
|
||||||
return NotImplemented
|
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
|
||||||
return hash((self.__class__.__name__, self.id))
|
|
||||||
|
|
||||||
|
|
||||||
class BaseDrop:
|
class BaseDrop:
|
||||||
def __init__(self, campaign: DropsCampaign, data: JsonType, claimed_benefits: Set[str]):
|
def __init__(self, campaign: DropsCampaign, data: JsonType, claimed_benefits: Set[str]):
|
||||||
self._twitch: Twitch = campaign._twitch
|
self._twitch: Twitch = campaign._twitch
|
||||||
@@ -53,6 +27,9 @@ class BaseDrop:
|
|||||||
self.claim_id = data["self"]["dropInstanceID"]
|
self.claim_id = data["self"]["dropInstanceID"]
|
||||||
self.is_claimed = data["self"]["isClaimed"]
|
self.is_claimed = data["self"]["isClaimed"]
|
||||||
elif all(b["benefit"]["id"] in claimed_benefits for b in data["benefitEdges"]):
|
elif all(b["benefit"]["id"] in claimed_benefits for b in data["benefitEdges"]):
|
||||||
|
# NOTE: this may erroneously mark drops from an unprogressed campaign as claimed
|
||||||
|
# if the benefits repeat, but shouldn't cause a problem
|
||||||
|
# once the campaign is in-progress
|
||||||
self.is_claimed = True
|
self.is_claimed = True
|
||||||
self._precondition_drops: List[str] = [d["id"] for d in (data["preconditionDrops"] or [])]
|
self._precondition_drops: List[str] = [d["id"] for d in (data["preconditionDrops"] or [])]
|
||||||
|
|
||||||
@@ -84,7 +61,7 @@ class BaseDrop:
|
|||||||
return self.claim_id is not None
|
return self.claim_id is not None
|
||||||
|
|
||||||
def _on_claim(self) -> None:
|
def _on_claim(self) -> None:
|
||||||
_invalidate_cache(self, "preconditions")
|
invalidate_cache(self, "preconditions")
|
||||||
|
|
||||||
def update_claim(self, claim_id: str):
|
def update_claim(self, claim_id: str):
|
||||||
self.claim_id = claim_id
|
self.claim_id = claim_id
|
||||||
@@ -161,7 +138,7 @@ class TimedDrop(BaseDrop):
|
|||||||
return self.current_minutes / self.required_minutes
|
return self.current_minutes / self.required_minutes
|
||||||
|
|
||||||
def _on_minutes_changed(self) -> None:
|
def _on_minutes_changed(self) -> None:
|
||||||
_invalidate_cache(self, "progress", "remaining_minutes")
|
invalidate_cache(self, "progress", "remaining_minutes")
|
||||||
self.campaign._on_minutes_changed()
|
self.campaign._on_minutes_changed()
|
||||||
|
|
||||||
async def claim(self) -> bool:
|
async def claim(self) -> bool:
|
||||||
@@ -192,9 +169,13 @@ class DropsCampaign:
|
|||||||
self.starts_at: datetime = datetime.strptime(data["startAt"], "%Y-%m-%dT%H:%M:%SZ")
|
self.starts_at: datetime = datetime.strptime(data["startAt"], "%Y-%m-%dT%H:%M:%SZ")
|
||||||
self.ends_at: datetime = datetime.strptime(data["endAt"], "%Y-%m-%dT%H:%M:%SZ")
|
self.ends_at: datetime = datetime.strptime(data["endAt"], "%Y-%m-%dT%H:%M:%SZ")
|
||||||
allowed = data["allow"]
|
allowed = data["allow"]
|
||||||
self.allowed_channels: List[str] = []
|
self.allowed_channels: List[Channel] = (
|
||||||
if allowed["channels"] is not None:
|
[
|
||||||
self.allowed_channels.extend(ch["name"] for ch in allowed["channels"])
|
Channel(twitch, ch["id"], ch["displayName"], priority=True)
|
||||||
|
for ch in allowed["channels"]
|
||||||
|
]
|
||||||
|
if allowed["channels"] and allowed["isEnabled"] else []
|
||||||
|
)
|
||||||
self.timed_drops: Dict[str, TimedDrop] = {
|
self.timed_drops: Dict[str, TimedDrop] = {
|
||||||
drop_data["id"]: TimedDrop(self, drop_data, claimed_benefits)
|
drop_data["id"]: TimedDrop(self, drop_data, claimed_benefits)
|
||||||
for drop_data in data["timeBasedDrops"]
|
for drop_data in data["timeBasedDrops"]
|
||||||
@@ -203,6 +184,10 @@ class DropsCampaign:
|
|||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"Campaign({self.name}({self.game!s}), {self.claimed_drops}/{self.total_drops})"
|
return f"Campaign({self.name}({self.game!s}), {self.claimed_drops}/{self.total_drops})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def drops(self) -> Iterable[TimedDrop]:
|
||||||
|
return self.timed_drops.values()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active(self):
|
def active(self):
|
||||||
return self.starts_at <= datetime.utcnow() < self.ends_at
|
return self.starts_at <= datetime.utcnow() < self.ends_at
|
||||||
@@ -221,11 +206,11 @@ class DropsCampaign:
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def claimed_drops(self) -> int:
|
def claimed_drops(self) -> int:
|
||||||
return sum(d.is_claimed for d in self.timed_drops.values())
|
return sum(d.is_claimed for d in self.drops)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def remaining_drops(self) -> int:
|
def remaining_drops(self) -> int:
|
||||||
return sum(not d.is_claimed for d in self.timed_drops.values())
|
return sum(not d.is_claimed for d in self.drops)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def remaining_minutes(self) -> int:
|
def remaining_minutes(self) -> int:
|
||||||
@@ -233,12 +218,12 @@ class DropsCampaign:
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def progress(self) -> float:
|
def progress(self) -> float:
|
||||||
return sum(d.progress for d in self.timed_drops.values()) / self.total_drops
|
return sum(d.progress for d in self.drops) / self.total_drops
|
||||||
|
|
||||||
def _on_claim(self) -> None:
|
def _on_claim(self) -> None:
|
||||||
_invalidate_cache(self, "claimed_drops", "remaining_drops")
|
invalidate_cache(self, "claimed_drops", "remaining_drops")
|
||||||
for drop in self.timed_drops.values():
|
for drop in self.drops:
|
||||||
drop._on_claim()
|
drop._on_claim()
|
||||||
|
|
||||||
def _on_minutes_changed(self) -> None:
|
def _on_minutes_changed(self) -> None:
|
||||||
_invalidate_cache(self, "progress", "remaining_minutes")
|
invalidate_cache(self, "progress", "remaining_minutes")
|
||||||
@@ -21,6 +21,7 @@ class ParsedArgs(argparse.Namespace):
|
|||||||
_debug_gql: bool
|
_debug_gql: bool
|
||||||
log: bool
|
log: bool
|
||||||
tray: bool
|
tray: bool
|
||||||
|
debug: bool
|
||||||
game: Optional[str]
|
game: Optional[str]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -54,15 +55,6 @@ class ParsedArgs(argparse.Namespace):
|
|||||||
return logging.NOTSET
|
return logging.NOTSET
|
||||||
|
|
||||||
|
|
||||||
# check if we're not already running
|
|
||||||
try:
|
|
||||||
exists = ctypes.windll.user32.FindWindowW(None, WINDOW_TITLE)
|
|
||||||
except AttributeError:
|
|
||||||
# we're not on Windows - continue
|
|
||||||
exists = False
|
|
||||||
if exists:
|
|
||||||
# already running - exit
|
|
||||||
sys.exit()
|
|
||||||
# handle input parameters
|
# handle input parameters
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
"Twitch Drops Miner (by DevilXD).exe",
|
"Twitch Drops Miner (by DevilXD).exe",
|
||||||
@@ -70,12 +62,22 @@ parser = argparse.ArgumentParser(
|
|||||||
)
|
)
|
||||||
parser.add_argument("-V", "--version", action="version", version=f"v{__version__}")
|
parser.add_argument("-V", "--version", action="version", version=f"v{__version__}")
|
||||||
parser.add_argument("-v", dest="_verbose", action="count", default=0)
|
parser.add_argument("-v", dest="_verbose", action="count", default=0)
|
||||||
|
parser.add_argument("--debug", dest="debug", action="store_true")
|
||||||
parser.add_argument("--debug-ws", dest="_debug_ws", action="store_true")
|
parser.add_argument("--debug-ws", dest="_debug_ws", action="store_true")
|
||||||
parser.add_argument("--debug-gql", dest="_debug_gql", action="store_true")
|
parser.add_argument("--debug-gql", dest="_debug_gql", action="store_true")
|
||||||
parser.add_argument("-g", "--game", default=None)
|
parser.add_argument("-g", "--game", default=None)
|
||||||
parser.add_argument("--tray", action="store_true")
|
parser.add_argument("--tray", action="store_true")
|
||||||
parser.add_argument("-l", "--log", action="store_true")
|
parser.add_argument("-l", "--log", action="store_true")
|
||||||
options: ParsedArgs = parser.parse_args(namespace=ParsedArgs())
|
options: ParsedArgs = parser.parse_args(namespace=ParsedArgs())
|
||||||
|
# check if we're not already running
|
||||||
|
try:
|
||||||
|
exists = ctypes.windll.user32.FindWindowW(None, WINDOW_TITLE)
|
||||||
|
except AttributeError:
|
||||||
|
# we're not on Windows - continue
|
||||||
|
exists = False
|
||||||
|
if exists and not options.debug:
|
||||||
|
# already running - exit
|
||||||
|
sys.exit()
|
||||||
# handle logging stuff
|
# handle logging stuff
|
||||||
if options.logging_level > logging.DEBUG:
|
if options.logging_level > logging.DEBUG:
|
||||||
# redirect the root logger into a NullHandler, effectively ignoring all logging calls
|
# redirect the root logger into a NullHandler, effectively ignoring all logging calls
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from time import time
|
|||||||
from itertools import chain
|
from itertools import chain
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import (
|
from typing import (
|
||||||
Optional, Union, List, Dict, Set, Callable, Iterable, Generic, TypeVar, cast, TYPE_CHECKING
|
Optional, Union, List, Dict, Set, OrderedDict, Callable, Iterable, cast, TYPE_CHECKING
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -17,10 +17,11 @@ except ModuleNotFoundError as exc:
|
|||||||
raise ImportError("You have to run 'pip install aiohttp' first") from exc
|
raise ImportError("You have to run 'pip install aiohttp' first") from exc
|
||||||
|
|
||||||
from channel import Channel
|
from channel import Channel
|
||||||
|
from websocket import WebsocketPool
|
||||||
from gui import GUIManager, LoginData
|
from gui import GUIManager, LoginData
|
||||||
from websocket import WebsocketPool, task_wrapper
|
|
||||||
from inventory import DropsCampaign, Game, TimedDrop
|
from inventory import DropsCampaign, Game, TimedDrop
|
||||||
from exceptions import LoginException, CaptchaRequired
|
from exceptions import LoginException, CaptchaRequired
|
||||||
|
from utils import AwaitableValue, OrderedSet, task_wrapper
|
||||||
from constants import (
|
from constants import (
|
||||||
State,
|
State,
|
||||||
JsonType,
|
JsonType,
|
||||||
@@ -44,35 +45,6 @@ logger = logging.getLogger("TwitchDrops")
|
|||||||
gql_logger = logging.getLogger("TwitchDrops.gql")
|
gql_logger = logging.getLogger("TwitchDrops.gql")
|
||||||
|
|
||||||
|
|
||||||
_V = TypeVar("_V")
|
|
||||||
_D = TypeVar("_D")
|
|
||||||
|
|
||||||
|
|
||||||
class _AwaitableValue(Generic[_V]):
|
|
||||||
def __init__(self):
|
|
||||||
self._value: _V
|
|
||||||
self._event = asyncio.Event()
|
|
||||||
|
|
||||||
def has_value(self) -> bool:
|
|
||||||
return self._event.is_set()
|
|
||||||
|
|
||||||
def get_with_default(self, default: _D) -> Union[_D, _V]:
|
|
||||||
if self._event.is_set():
|
|
||||||
return self._value
|
|
||||||
return default
|
|
||||||
|
|
||||||
async def get(self) -> _V:
|
|
||||||
await self._event.wait()
|
|
||||||
return self._value
|
|
||||||
|
|
||||||
def set(self, value: _V) -> None:
|
|
||||||
self._value = value
|
|
||||||
self._event.set()
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
self._event.clear()
|
|
||||||
|
|
||||||
|
|
||||||
class Twitch:
|
class Twitch:
|
||||||
def __init__(self, loop: asyncio.AbstractEventLoop, options: ParsedArgs):
|
def __init__(self, loop: asyncio.AbstractEventLoop, options: ParsedArgs):
|
||||||
self._loop = loop
|
self._loop = loop
|
||||||
@@ -95,10 +67,11 @@ class Twitch:
|
|||||||
# State management
|
# State management
|
||||||
self._state: State = State.INVENTORY_FETCH
|
self._state: State = State.INVENTORY_FETCH
|
||||||
self._state_change = asyncio.Event()
|
self._state_change = asyncio.Event()
|
||||||
self.inventory: List[DropsCampaign] = []
|
self.inventory: Dict[Game, List[DropsCampaign]] = {}
|
||||||
|
self.game: Optional[Game] = None
|
||||||
# Storing and watching channels
|
# Storing and watching channels
|
||||||
self.channels: Dict[int, Channel] = {}
|
self.channels: OrderedDict[int, Channel] = OrderedDict()
|
||||||
self._watching_channel: _AwaitableValue[Channel] = _AwaitableValue()
|
self.watching_channel: AwaitableValue[Channel] = AwaitableValue()
|
||||||
self._watching_task: Optional[asyncio.Task[None]] = None
|
self._watching_task: Optional[asyncio.Task[None]] = None
|
||||||
self._watching_restart = asyncio.Event()
|
self._watching_restart = asyncio.Event()
|
||||||
self._drop_update: Optional[asyncio.Future[bool]] = None
|
self._drop_update: Optional[asyncio.Future[bool]] = None
|
||||||
@@ -166,7 +139,7 @@ class Twitch:
|
|||||||
await asyncio.sleep(start_time + 1 - time())
|
await asyncio.sleep(start_time + 1 - time())
|
||||||
|
|
||||||
def is_watching(self, channel: Channel) -> bool:
|
def is_watching(self, channel: Channel) -> bool:
|
||||||
watching_channel = self._watching_channel.get_with_default(None)
|
watching_channel = self.watching_channel.get_with_default(None)
|
||||||
return watching_channel is not None and watching_channel == channel
|
return watching_channel is not None and watching_channel == channel
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
@@ -188,7 +161,6 @@ class Twitch:
|
|||||||
WebsocketTopic("User", "CommunityPoints", self._user_id, self.process_points),
|
WebsocketTopic("User", "CommunityPoints", self._user_id, self.process_points),
|
||||||
])
|
])
|
||||||
games: List[Game] = []
|
games: List[Game] = []
|
||||||
selected_game: Optional[Game] = None
|
|
||||||
self.change_state(State.INVENTORY_FETCH)
|
self.change_state(State.INVENTORY_FETCH)
|
||||||
while True:
|
while True:
|
||||||
if self._state is State.IDLE:
|
if self._state is State.IDLE:
|
||||||
@@ -200,17 +172,23 @@ class Twitch:
|
|||||||
elif self._state is State.GAMES_UPDATE:
|
elif self._state is State.GAMES_UPDATE:
|
||||||
# Figure out which games to watch, and claim the drops we can
|
# Figure out which games to watch, and claim the drops we can
|
||||||
games.clear()
|
games.clear()
|
||||||
for campaign in self.inventory:
|
for game, campaigns in self.inventory.items():
|
||||||
# we have no use in processing upcoming campaigns here
|
add_game = False
|
||||||
if not campaign.upcoming:
|
for campaign in campaigns:
|
||||||
for drop in campaign.timed_drops.values():
|
if not campaign.upcoming:
|
||||||
if drop.can_claim:
|
# claim drops from expired and active campaigns
|
||||||
await drop.claim()
|
active = campaign.active
|
||||||
if drop.can_earn and (game := campaign.game) not in games:
|
for drop in campaign.drops:
|
||||||
games.append(game)
|
if drop.can_claim:
|
||||||
|
await drop.claim()
|
||||||
|
# add game only for active campaigns
|
||||||
|
if active and not add_game and drop.can_earn:
|
||||||
|
add_game = True
|
||||||
|
if add_game:
|
||||||
|
games.append(game)
|
||||||
self.change_state(State.GAME_SELECT)
|
self.change_state(State.GAME_SELECT)
|
||||||
elif self._state is State.GAME_SELECT:
|
elif self._state is State.GAME_SELECT:
|
||||||
# 'games' has all games we want to mine drops for
|
# 'games' has all games we can mine drops for
|
||||||
# if it's empty, there's no point in continuing
|
# if it's empty, there's no point in continuing
|
||||||
if not games:
|
if not games:
|
||||||
self.gui.print("No active campaigns to mine drops for.")
|
self.gui.print("No active campaigns to mine drops for.")
|
||||||
@@ -218,20 +196,25 @@ class Twitch:
|
|||||||
# only start the websocket after we confirm there are drops to mine
|
# only start the websocket after we confirm there are drops to mine
|
||||||
await self.websocket.start()
|
await self.websocket.start()
|
||||||
self.gui.games.set_games(games)
|
self.gui.games.set_games(games)
|
||||||
selected_game = self.gui.games.get_selection()
|
self.game = self.gui.games.get_selection()
|
||||||
# pre-display the active drop without a countdown
|
# pre-display the active drop without a countdown
|
||||||
active_drop = self.get_active_drop(selected_game)
|
active_drop = self.get_active_drop()
|
||||||
if active_drop is not None:
|
if active_drop is not None:
|
||||||
active_drop.display(countdown=False)
|
active_drop.display(countdown=False)
|
||||||
|
self.restart_watching()
|
||||||
self.change_state(State.CHANNELS_CLEANUP)
|
self.change_state(State.CHANNELS_CLEANUP)
|
||||||
elif self._state is State.CHANNELS_CLEANUP:
|
elif self._state is State.CHANNELS_CLEANUP:
|
||||||
# remove all channels that are offline,
|
# remove all channels that are offline,
|
||||||
# or aren't streaming the game we want anymore
|
# or aren't streaming the game we want anymore
|
||||||
to_remove: List[Channel] = [
|
if self.game is None:
|
||||||
channel for channel in self.channels.values()
|
# remove everything
|
||||||
if not (channel.online or channel.pending_online)
|
to_remove: List[Channel] = list(self.channels.values())
|
||||||
or channel.game is None or channel.game != selected_game
|
else:
|
||||||
]
|
to_remove = [
|
||||||
|
channel for channel in self.channels.values()
|
||||||
|
if not (channel.online or channel.pending_online)
|
||||||
|
or channel.game is None or channel.game != self.game
|
||||||
|
]
|
||||||
self.websocket.remove_topics(
|
self.websocket.remove_topics(
|
||||||
WebsocketTopic.as_str("Channel", "VideoPlayback", channel.id)
|
WebsocketTopic.as_str("Channel", "VideoPlayback", channel.id)
|
||||||
for channel in to_remove
|
for channel in to_remove
|
||||||
@@ -245,26 +228,42 @@ class Twitch:
|
|||||||
self.gui.channels.shrink()
|
self.gui.channels.shrink()
|
||||||
self.change_state(State.CHANNELS_FETCH)
|
self.change_state(State.CHANNELS_FETCH)
|
||||||
elif self._state is State.CHANNELS_FETCH:
|
elif self._state is State.CHANNELS_FETCH:
|
||||||
if selected_game is None:
|
if self.game is None:
|
||||||
self.change_state(State.GAME_SELECT)
|
self.change_state(State.GAME_SELECT)
|
||||||
else:
|
else:
|
||||||
# get a list of all live channels with drops enabled
|
# pre-display the active drop without substracting a minute
|
||||||
live_streams: List[Channel] = await self.get_live_streams(
|
active_drop = self.get_active_drop()
|
||||||
selected_game, [DROPS_ENABLED_TAG]
|
if active_drop is not None:
|
||||||
)
|
active_drop.display(countdown=False)
|
||||||
if live_streams:
|
# gather ACLs from campaigns
|
||||||
|
no_acl = False
|
||||||
|
new_channels: OrderedSet[Channel] = OrderedSet()
|
||||||
|
for campaign in self.inventory[self.game]:
|
||||||
|
acls = campaign.allowed_channels
|
||||||
|
if acls:
|
||||||
|
for channel in acls:
|
||||||
|
new_channels.add(channel)
|
||||||
|
else:
|
||||||
|
no_acl = True
|
||||||
|
# set them online if possible
|
||||||
|
await asyncio.gather(*(channel.get_stream() for channel in new_channels))
|
||||||
|
if no_acl:
|
||||||
|
# if there's at least one game without an ACL,
|
||||||
|
# get a list of all live channels with drops enabled
|
||||||
|
live_streams: List[Channel] = await self.get_live_streams()
|
||||||
|
for channel in live_streams:
|
||||||
|
new_channels.add(channel)
|
||||||
|
if new_channels:
|
||||||
# there are online streams, so let's pre-display the active drop again,
|
# there are online streams, so let's pre-display the active drop again,
|
||||||
# but this time with a substracted minute
|
# but this time with a substracted minute
|
||||||
active_drop = self.get_active_drop(selected_game)
|
active_drop = self.get_active_drop()
|
||||||
if active_drop is not None:
|
if active_drop is not None:
|
||||||
active_drop.display(countdown=False, subone=True)
|
active_drop.display(countdown=False, subone=True)
|
||||||
# add them, filtering out ones we already have
|
# add them, filtering out ones we already have
|
||||||
for channel in live_streams:
|
for channel in new_channels:
|
||||||
if channel.id not in self.channels:
|
if channel.id not in self.channels:
|
||||||
self.channels[channel.id] = channel
|
self.channels[channel.id] = channel
|
||||||
channel.display()
|
channel.display()
|
||||||
# load points
|
|
||||||
# asyncio.gather(*(channel.claim_bonus() for channel in live_streams))
|
|
||||||
# Sub to these channel updates
|
# Sub to these channel updates
|
||||||
topics: List[WebsocketTopic] = [
|
topics: List[WebsocketTopic] = [
|
||||||
WebsocketTopic(
|
WebsocketTopic(
|
||||||
@@ -275,7 +274,7 @@ class Twitch:
|
|||||||
self.websocket.add_topics(topics)
|
self.websocket.add_topics(topics)
|
||||||
self.change_state(State.CHANNEL_SWITCH)
|
self.change_state(State.CHANNEL_SWITCH)
|
||||||
elif self._state is State.CHANNEL_SWITCH:
|
elif self._state is State.CHANNEL_SWITCH:
|
||||||
if selected_game is None:
|
if self.game is None:
|
||||||
self.change_state(State.GAME_SELECT)
|
self.change_state(State.GAME_SELECT)
|
||||||
else:
|
else:
|
||||||
# Change into the selected channel, stay in the watching channel,
|
# Change into the selected channel, stay in the watching channel,
|
||||||
@@ -286,24 +285,20 @@ class Twitch:
|
|||||||
if selected_channel is not None:
|
if selected_channel is not None:
|
||||||
self.gui.channels.clear_selection()
|
self.gui.channels.clear_selection()
|
||||||
priority_channels.append(selected_channel)
|
priority_channels.append(selected_channel)
|
||||||
watching_channel = self._watching_channel.get_with_default(None)
|
watching_channel = self.watching_channel.get_with_default(None)
|
||||||
if watching_channel is not None:
|
if watching_channel is not None:
|
||||||
priority_channels.append(watching_channel)
|
priority_channels.append(watching_channel)
|
||||||
channels = chain(priority_channels, self.channels.values())
|
channels = chain(priority_channels, self.channels.values())
|
||||||
# If there's no selected channel, change into a channel we can watch
|
# If there's no selected channel, change into a channel we can watch
|
||||||
for channel in channels:
|
for channel in channels:
|
||||||
if (
|
if self.can_watch(channel):
|
||||||
channel.online # steam online
|
|
||||||
and channel.drops_enabled # drops are enabled
|
|
||||||
and channel.game == selected_game # it's a game we've selected
|
|
||||||
):
|
|
||||||
self.watch(channel)
|
self.watch(channel)
|
||||||
# break the state change chain by clearing the flag
|
# break the state change chain by clearing the flag
|
||||||
self._state_change.clear()
|
self._state_change.clear()
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
self.stop_watching()
|
self.stop_watching()
|
||||||
self.gui.print(f"No suitable channel to watch for game: {selected_game}")
|
self.gui.print(f"No suitable channel to watch for game: {self.game}")
|
||||||
self.change_state(State.IDLE)
|
self.change_state(State.IDLE)
|
||||||
elif self._state is State.EXIT:
|
elif self._state is State.EXIT:
|
||||||
# we've been requested to exit the application
|
# we've been requested to exit the application
|
||||||
@@ -315,7 +310,7 @@ class Twitch:
|
|||||||
interval = WATCH_INTERVAL.total_seconds()
|
interval = WATCH_INTERVAL.total_seconds()
|
||||||
i = 1
|
i = 1
|
||||||
while True:
|
while True:
|
||||||
channel = await self._watching_channel.get()
|
channel = await self.watching_channel.get()
|
||||||
await channel.send_watch()
|
await channel.send_watch()
|
||||||
last_watch = time()
|
last_watch = time()
|
||||||
self._drop_update = asyncio.Future()
|
self._drop_update = asyncio.Future()
|
||||||
@@ -347,8 +342,7 @@ class Twitch:
|
|||||||
# Sometimes, even GQL fails to give us the correct drop.
|
# Sometimes, even GQL fails to give us the correct drop.
|
||||||
# In that case, we can use the locally cached inventory to try
|
# In that case, we can use the locally cached inventory to try
|
||||||
# and put together the drop that we're actually mining right now
|
# and put together the drop that we're actually mining right now
|
||||||
selected_game = self.gui.games.get_selection()
|
drop = self.get_active_drop()
|
||||||
drop = self.get_active_drop(selected_game)
|
|
||||||
if drop is not None:
|
if drop is not None:
|
||||||
drop.bump_minutes()
|
drop.bump_minutes()
|
||||||
drop.display()
|
drop.display()
|
||||||
@@ -371,27 +365,37 @@ class Twitch:
|
|||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def can_watch(self, channel: Channel) -> bool:
|
||||||
|
if self.game is None:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
channel.online # steam online
|
||||||
|
and channel.drops_enabled # drops are enabled
|
||||||
|
and channel.game == self.game # it's a game we've selected
|
||||||
|
# we can progress any campaign for the selected game
|
||||||
|
and any(
|
||||||
|
drop.can_earn
|
||||||
|
for campaign in self.inventory[self.game]
|
||||||
|
if not campaign.allowed_channels or channel in campaign.allowed_channels
|
||||||
|
for drop in campaign.drops
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def watch(self, channel: Channel):
|
def watch(self, channel: Channel):
|
||||||
if self.is_watching(channel):
|
if self.is_watching(channel):
|
||||||
# we're already watching the same channel, so there's no point switching
|
# we're already watching the same channel, so there's no point switching
|
||||||
return
|
return
|
||||||
self.gui.channels.set_watching(channel)
|
self.gui.channels.set_watching(channel)
|
||||||
self._watching_channel.set(channel)
|
self.watching_channel.set(channel)
|
||||||
|
|
||||||
def stop_watching(self):
|
def stop_watching(self):
|
||||||
self.gui.progress.stop_timer()
|
self.gui.progress.stop_timer()
|
||||||
self.gui.channels.clear_watching()
|
self.gui.channels.clear_watching()
|
||||||
self._watching_channel.clear()
|
self.watching_channel.clear()
|
||||||
|
|
||||||
def restart_watching(self, channel: Optional[Channel] = None):
|
def restart_watching(self):
|
||||||
# this forcibly re-sends the watching payload to the specified or currently watched channel
|
self.gui.progress.stop_timer()
|
||||||
if channel is None:
|
self._watching_restart.set()
|
||||||
channel = self._watching_channel.get_with_default(None)
|
|
||||||
if channel is not None:
|
|
||||||
self.gui.progress.stop_timer()
|
|
||||||
self._watching_channel.set(channel)
|
|
||||||
self.gui.channels.set_watching(channel)
|
|
||||||
self._watching_restart.set()
|
|
||||||
|
|
||||||
@task_wrapper
|
@task_wrapper
|
||||||
async def process_stream_state(self, channel_id: int, message: JsonType):
|
async def process_stream_state(self, channel_id: int, message: JsonType):
|
||||||
@@ -401,14 +405,8 @@ class Twitch:
|
|||||||
logger.error(f"Stream state change for a non-existing channel: {channel_id}")
|
logger.error(f"Stream state change for a non-existing channel: {channel_id}")
|
||||||
return
|
return
|
||||||
if msg_type == "stream-down":
|
if msg_type == "stream-down":
|
||||||
logger.info(f"{channel.name} goes OFFLINE")
|
|
||||||
channel.set_offline()
|
channel.set_offline()
|
||||||
if self.is_watching(channel):
|
|
||||||
self.gui.print(f"{channel.name} goes OFFLINE, switching...")
|
|
||||||
# change the channel if we're currently watching it
|
|
||||||
self.change_state(State.CHANNEL_SWITCH)
|
|
||||||
elif msg_type == "stream-up":
|
elif msg_type == "stream-up":
|
||||||
logger.info(f"{channel.name} goes ONLINE")
|
|
||||||
channel.set_online()
|
channel.set_online()
|
||||||
elif msg_type == "viewcount":
|
elif msg_type == "viewcount":
|
||||||
if not channel.online:
|
if not channel.online:
|
||||||
@@ -417,7 +415,28 @@ class Twitch:
|
|||||||
else:
|
else:
|
||||||
viewers = message["viewers"]
|
viewers = message["viewers"]
|
||||||
channel.viewers = viewers
|
channel.viewers = viewers
|
||||||
logger.debug(f"{channel.name} viewers: {viewers}")
|
# logger.debug(f"{channel.name} viewers: {viewers}")
|
||||||
|
|
||||||
|
def on_online(self, channel: Channel):
|
||||||
|
"""
|
||||||
|
Called by a Channel when it goes online (after pending).
|
||||||
|
"""
|
||||||
|
logger.debug(f"{channel.name} goes ONLINE")
|
||||||
|
if channel.priority:
|
||||||
|
wch = self.watching_channel.get_with_default(None)
|
||||||
|
if wch is not None and not wch.priority and self.can_watch(channel):
|
||||||
|
self.watch(channel)
|
||||||
|
|
||||||
|
def on_offline(self, channel: Channel):
|
||||||
|
"""
|
||||||
|
Called by a Channel when it goes offline.
|
||||||
|
"""
|
||||||
|
# change the channel if we're currently watching it
|
||||||
|
if self.is_watching(channel):
|
||||||
|
self.gui.print(f"{channel.name} goes OFFLINE, switching...")
|
||||||
|
self.change_state(State.CHANNEL_SWITCH)
|
||||||
|
else:
|
||||||
|
logger.debug(f"{channel.name} goes OFFLINE")
|
||||||
|
|
||||||
@task_wrapper
|
@task_wrapper
|
||||||
async def process_drops(self, user_id: int, message: JsonType):
|
async def process_drops(self, user_id: int, message: JsonType):
|
||||||
@@ -706,9 +725,10 @@ class Twitch:
|
|||||||
# fetch all available campaign IDs, that are currently ACTIVE and account is connected
|
# fetch all available campaign IDs, that are currently ACTIVE and account is connected
|
||||||
response = await self.gql_request(GQL_OPERATIONS["Campaigns"])
|
response = await self.gql_request(GQL_OPERATIONS["Campaigns"])
|
||||||
data = response["data"]["currentUser"]["dropCampaigns"] or []
|
data = response["data"]["currentUser"]["dropCampaigns"] or []
|
||||||
|
applicable_statuses = ("ACTIVE", "UPCOMING")
|
||||||
available_campaigns: Set[str] = set(
|
available_campaigns: Set[str] = set(
|
||||||
c["id"] for c in data
|
c["id"] for c in data
|
||||||
if c["status"] == "ACTIVE" and c["self"]["isAccountConnected"]
|
if c["status"] in applicable_statuses and c["self"]["isAccountConnected"]
|
||||||
)
|
)
|
||||||
# fetch in-progress campaigns (inventory)
|
# fetch in-progress campaigns (inventory)
|
||||||
response = await self.gql_request(GQL_OPERATIONS["Inventory"])
|
response = await self.gql_request(GQL_OPERATIONS["Inventory"])
|
||||||
@@ -727,26 +747,36 @@ class Twitch:
|
|||||||
# add campaigns that remained, that can be earned but are not in-progress yet
|
# add campaigns that remained, that can be earned but are not in-progress yet
|
||||||
for campaign_id in available_campaigns:
|
for campaign_id in available_campaigns:
|
||||||
campaign = await self.fetch_campaign(campaign_id, claimed_benefits)
|
campaign = await self.fetch_campaign(campaign_id, claimed_benefits)
|
||||||
if any(drop.can_earn for drop in campaign.timed_drops.values()):
|
if any(drop.can_earn for drop in campaign.drops):
|
||||||
campaigns.append(campaign)
|
campaigns.append(campaign)
|
||||||
campaigns.sort(key=lambda c: c.ends_at)
|
campaigns.sort(key=lambda c: c.ends_at)
|
||||||
self.inventory = campaigns
|
self.inventory.clear()
|
||||||
|
for campaign in campaigns:
|
||||||
|
game = campaign.game
|
||||||
|
if game not in self.inventory:
|
||||||
|
self.inventory[game] = []
|
||||||
|
self.inventory[game].append(campaign)
|
||||||
|
|
||||||
def get_drop(self, drop_id: str) -> Optional[TimedDrop]:
|
def get_drop(self, drop_id: str) -> Optional[TimedDrop]:
|
||||||
for campaign in self.inventory:
|
for campaign in chain(*self.inventory.values()):
|
||||||
drop = campaign.timed_drops.get(drop_id)
|
drop = campaign.timed_drops.get(drop_id)
|
||||||
if drop is not None:
|
if drop is not None:
|
||||||
return drop
|
return drop
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_active_drop(self, game: Game) -> Optional[TimedDrop]:
|
def get_active_drop(self) -> Optional[TimedDrop]:
|
||||||
|
if self.game is None:
|
||||||
|
return None
|
||||||
|
watching_channel = self.watching_channel.get_with_default(None)
|
||||||
drops = sorted(
|
drops = sorted(
|
||||||
(
|
(
|
||||||
drop
|
drop
|
||||||
for campaign in self.inventory
|
for campaign in self.inventory[self.game]
|
||||||
if campaign.active and campaign.game == game
|
if campaign.active
|
||||||
for drop in campaign.timed_drops.values()
|
for drop in campaign.drops
|
||||||
if drop.can_earn
|
if drop.can_earn and (
|
||||||
|
watching_channel is None or watching_channel in campaign.allowed_channels
|
||||||
|
)
|
||||||
),
|
),
|
||||||
key=lambda d: d.remaining_minutes,
|
key=lambda d: d.remaining_minutes,
|
||||||
)
|
)
|
||||||
@@ -754,15 +784,17 @@ class Twitch:
|
|||||||
return drops[0]
|
return drops[0]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_live_streams(self, game: Game, tag_ids: List[str]) -> List[Channel]:
|
async def get_live_streams(self) -> List[Channel]:
|
||||||
|
if self.game is None:
|
||||||
|
return []
|
||||||
limit = 45
|
limit = 45
|
||||||
response = await self.gql_request(
|
response = await self.gql_request(
|
||||||
GQL_OPERATIONS["GameDirectory"].with_variables({
|
GQL_OPERATIONS["GameDirectory"].with_variables({
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"name": game.name,
|
"name": self.game.name,
|
||||||
"options": {
|
"options": {
|
||||||
"includeRestricted": ["SUB_ONLY_LIVE"],
|
"includeRestricted": ["SUB_ONLY_LIVE"],
|
||||||
"tags": tag_ids,
|
"tags": [DROPS_ENABLED_TAG],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
from contextlib import suppress
|
||||||
|
from collections import OrderedDict
|
||||||
|
from typing import Union, List, MutableSet, Iterable, Iterator, Generic, TypeVar
|
||||||
|
|
||||||
|
from constants import JsonType
|
||||||
|
|
||||||
|
|
||||||
|
_V = TypeVar("_V")
|
||||||
|
_D = TypeVar("_D")
|
||||||
|
logger = logging.getLogger("TwitchDrops")
|
||||||
|
NONCE_CHARS = string.ascii_letters + string.digits
|
||||||
|
|
||||||
|
|
||||||
|
def create_nonce(length: int = 30) -> str:
|
||||||
|
return ''.join(random.choices(NONCE_CHARS, k=length))
|
||||||
|
|
||||||
|
|
||||||
|
def deduplicate(iterable: Iterable[_V]) -> List[_V]:
|
||||||
|
return list(OrderedDict.fromkeys(iterable).keys())
|
||||||
|
|
||||||
|
|
||||||
|
def task_wrapper(afunc):
|
||||||
|
@wraps(afunc)
|
||||||
|
async def wrapper(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
await afunc(self, *args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Exception in task")
|
||||||
|
raise # raise up to the wrapping task
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_cache(instance, *attrnames):
|
||||||
|
"""
|
||||||
|
To be used to invalidate `functools.cached_property`.
|
||||||
|
"""
|
||||||
|
for name in attrnames:
|
||||||
|
with suppress(AttributeError):
|
||||||
|
delattr(instance, name)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderedSet(MutableSet[_V]):
|
||||||
|
"""
|
||||||
|
Implementation of a set that preserves insertion order,
|
||||||
|
based on OrderedDict with values set to None.
|
||||||
|
"""
|
||||||
|
def __init__(self, iterable: Iterable[_V] = []):
|
||||||
|
self._items: OrderedDict[_V, None] = OrderedDict((item, None) for item in iterable)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"{self.__class__.__name__}([{', '.join(map(repr, self._items))}])"
|
||||||
|
|
||||||
|
def __contains__(self, x: object) -> bool:
|
||||||
|
return x in self._items
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[_V]:
|
||||||
|
return iter(self._items)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._items)
|
||||||
|
|
||||||
|
def add(self, item: _V) -> None:
|
||||||
|
self._items[item] = None
|
||||||
|
|
||||||
|
def discard(self, item: _V) -> None:
|
||||||
|
with suppress(KeyError):
|
||||||
|
del self._items[item]
|
||||||
|
|
||||||
|
|
||||||
|
class AwaitableValue(Generic[_V]):
|
||||||
|
def __init__(self):
|
||||||
|
self._value: _V
|
||||||
|
self._event = asyncio.Event()
|
||||||
|
|
||||||
|
def has_value(self) -> bool:
|
||||||
|
return self._event.is_set()
|
||||||
|
|
||||||
|
def get_with_default(self, default: _D) -> Union[_D, _V]:
|
||||||
|
if self._event.is_set():
|
||||||
|
return self._value
|
||||||
|
return default
|
||||||
|
|
||||||
|
async def get(self) -> _V:
|
||||||
|
await self._event.wait()
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
def set(self, value: _V) -> None:
|
||||||
|
self._value = value
|
||||||
|
self._event.set()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._event.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class Game:
|
||||||
|
def __init__(self, data: JsonType):
|
||||||
|
self.id: int = int(data["id"])
|
||||||
|
self.name: str = data["name"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Game({self.id}, {self.name})"
|
||||||
|
|
||||||
|
def __eq__(self, other: object):
|
||||||
|
if isinstance(other, self.__class__):
|
||||||
|
return self.id == other.id
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
def __hash__(self) -> int:
|
||||||
|
return hash((self.__class__.__name__, self.id))
|
||||||
+1
-19
@@ -1,12 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import random
|
|
||||||
import string
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from time import time
|
from time import time
|
||||||
from functools import wraps
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from typing import Optional, List, Dict, Set, Iterable, TYPE_CHECKING
|
from typing import Optional, List, Dict, Set, Iterable, TYPE_CHECKING
|
||||||
|
|
||||||
@@ -14,6 +11,7 @@ from websockets.exceptions import ConnectionClosed, ConnectionClosedOK
|
|||||||
from websockets.client import WebSocketClientProtocol, connect as websocket_connect
|
from websockets.client import WebSocketClientProtocol, connect as websocket_connect
|
||||||
|
|
||||||
from exceptions import MinerException
|
from exceptions import MinerException
|
||||||
|
from utils import task_wrapper, create_nonce
|
||||||
from constants import (
|
from constants import (
|
||||||
JsonType,
|
JsonType,
|
||||||
WebsocketTopic,
|
WebsocketTopic,
|
||||||
@@ -30,22 +28,6 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
logger = logging.getLogger("TwitchDrops")
|
logger = logging.getLogger("TwitchDrops")
|
||||||
ws_logger = logging.getLogger("TwitchDrops.websocket")
|
ws_logger = logging.getLogger("TwitchDrops.websocket")
|
||||||
NONCE_CHARS = string.ascii_letters + string.digits
|
|
||||||
|
|
||||||
|
|
||||||
def create_nonce(length: int = 30) -> str:
|
|
||||||
return ''.join(random.choices(NONCE_CHARS, k=length))
|
|
||||||
|
|
||||||
|
|
||||||
def task_wrapper(afunc):
|
|
||||||
@wraps(afunc)
|
|
||||||
async def wrapper(self, *args, **kwargs):
|
|
||||||
try:
|
|
||||||
await afunc(self, *args, **kwargs)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Exception in task")
|
|
||||||
raise # raise up to the wrapping task
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
class Websocket:
|
class Websocket:
|
||||||
|
|||||||
Reference in new issue
Block a user