Implement automatic campaign discovery and starting

This commit is contained in:
DevilXD committed 2022-01-16 22:56:06 +01:00
1 parent cf1830cce9
commit 365a702d8a
3 files changed
+59 -27

No files matched your search

+4 -8
View File
@@ -6,15 +6,12 @@ This application allows you to AFK mine timed Twitch drops, without having to wo
- Stream-less drop mining - save on bandwidth. - Stream-less drop mining - save on bandwidth.
- Sharded websocket connection, allowing for tracking up to `8*50-2=398` channels at the same time. - Sharded websocket connection, allowing for tracking up to `8*50-2=398` channels at the same time.
- Automatic drop campaigns discovery based on linked accounts (requires you to do [account linking](https://www.twitch.tv/drops/campaigns) yourself though)
- Stream tags and drop campaign validation, so you don't end up watching a stream that can't earn you the drop. - Stream tags and drop campaign validation, so you don't end up watching a stream that can't earn you the drop.
- Channel stream switching, when the one you were currently watching goes offline. - Channel stream switching, when the one you were currently watching goes offline.
- Cookie saving between sessions, so you don't need to login every time. - Cookie saving between sessions, so you don't need to login every time.
- Mining is stopped automatically when the last available drop has been mined. - Mining is stopped automatically when the last available drop has been mined.
**Not managed / TODO / manual:**
- Adding additional drop campaigns from other games to your inventory (requires you to do [account linking](https://www.twitch.tv/drops/campaigns) yourself)
**Usage:** **Usage:**
- Download the [lastest release](https://github.com/DevilXD/TwitchDropsMiner/releases) - it's recommended to keep it in the folder it comes with. - Download the [lastest release](https://github.com/DevilXD/TwitchDropsMiner/releases) - it's recommended to keep it in the folder it comes with.
@@ -32,7 +29,6 @@ This application allows you to AFK mine timed Twitch drops, without having to wo
### "No active campaigns to mine drops for." ### "No active campaigns to mine drops for."
- Check your [inventory page](https://www.twitch.tv/drops/inventory) for in-progress campaigns. - Check your [inventory page](https://www.twitch.tv/drops/inventory) for in-progress campaigns - make sure you're not looking at upcoming campaigns. You can check when a campaign starts by hovering over the very first drop in the campaign.
- If there's none, use the [campaigns page](https://www.twitch.tv/drops/campaigns) to link your account to the campaign's game of choice. - Make sure to link your account on the [campaigns page](https://www.twitch.tv/drops/campaigns), to the campaign's game of choice.
- If your account is linked but the [inventory page](https://www.twitch.tv/drops/inventory) still doesn't include the campaign, go back to the [campaigns page](https://www.twitch.tv/drops/campaigns), expand the game of choice's campaign, and in the "How to Earn the Drop" section, click on "Go to a participating live channel" (or select one of the channels it lists) and make sure to watch the stream for at least one minute - this will activate the campaign. You can verify the campaign's activation on the [inventory page](https://www.twitch.tv/drops/inventory). - Once properly linked, the game should become available in the application's Game Selector.
- If there are any in-progress campaigns, make sure that they're started and not expired. You can check when a campaign starts by hovering over the very first drop in the campaign.
+19 -12
View File
@@ -2,7 +2,7 @@ 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, TYPE_CHECKING from typing import Optional, List, Dict, Set, TYPE_CHECKING
from constants import JsonType, GQL_OPERATIONS from constants import JsonType, GQL_OPERATIONS
@@ -39,7 +39,7 @@ class Game:
class BaseDrop: class BaseDrop:
def __init__(self, campaign: DropsCampaign, data: JsonType): def __init__(self, campaign: DropsCampaign, data: JsonType, claimed_benefits: Set[str]):
self._twitch: Twitch = campaign._twitch self._twitch: Twitch = campaign._twitch
self.id: str = data["id"] self.id: str = data["id"]
self.name: str = data["name"] self.name: str = data["name"]
@@ -47,9 +47,13 @@ class BaseDrop:
self.rewards: List[str] = [b["benefit"]["name"] for b in data["benefitEdges"]] self.rewards: List[str] = [b["benefit"]["name"] for b in data["benefitEdges"]]
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")
# If claim_id is not None, we can use it to claim the drop self.claim_id: Optional[str] = None
self.claim_id: Optional[str] = data["self"]["dropInstanceID"] self.is_claimed: bool = False
self.is_claimed: bool = data["self"]["isClaimed"] if "self" in data:
self.claim_id = data["self"]["dropInstanceID"]
self.is_claimed = data["self"]["isClaimed"]
elif all(b["benefit"]["id"] in claimed_benefits for b in data["benefitEdges"]):
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 [])]
def __repr__(self) -> str: def __repr__(self) -> str:
@@ -101,10 +105,10 @@ class BaseDrop:
""" """
Returns True if the claim succeeded, False otherwise. Returns True if the claim succeeded, False otherwise.
""" """
if not self.can_claim:
return False
if self.is_claimed: if self.is_claimed:
return True return True
if not self.can_claim:
return False
response = await self._twitch.gql_request( response = await self._twitch.gql_request(
GQL_OPERATIONS["ClaimDrop"].with_variables( GQL_OPERATIONS["ClaimDrop"].with_variables(
{"input": {"dropInstanceID": self.claim_id}} {"input": {"dropInstanceID": self.claim_id}}
@@ -125,9 +129,11 @@ class BaseDrop:
class TimedDrop(BaseDrop): class TimedDrop(BaseDrop):
def __init__(self, campaign: DropsCampaign, data: JsonType): def __init__(self, campaign: DropsCampaign, data: JsonType, claimed_benefits: Set[str]):
super().__init__(campaign, data) super().__init__(campaign, data, claimed_benefits)
self.current_minutes: int = data["self"]["currentMinutesWatched"] self.current_minutes: int = 0
if "self" in data:
self.current_minutes = data["self"]["currentMinutesWatched"]
self.required_minutes: int = data["requiredMinutesWatched"] self.required_minutes: int = data["requiredMinutesWatched"]
if self.is_claimed: if self.is_claimed:
# claimed drops report 0 current minutes, so we need to make a correction # claimed drops report 0 current minutes, so we need to make a correction
@@ -178,7 +184,7 @@ class TimedDrop(BaseDrop):
class DropsCampaign: class DropsCampaign:
def __init__(self, twitch: Twitch, data: JsonType): def __init__(self, twitch: Twitch, data: JsonType, claimed_benefits: Set[str]):
self._twitch: Twitch = twitch self._twitch: Twitch = twitch
self.id: str = data["id"] self.id: str = data["id"]
self.name: str = data["name"] self.name: str = data["name"]
@@ -190,7 +196,8 @@ class DropsCampaign:
if allowed["channels"] is not None: if allowed["channels"] is not None:
self.allowed_channels.extend(ch["name"] for ch in allowed["channels"]) self.allowed_channels.extend(ch["name"] for ch in allowed["channels"])
self.timed_drops: Dict[str, TimedDrop] = { self.timed_drops: Dict[str, TimedDrop] = {
d["id"]: TimedDrop(self, d) for d in data["timeBasedDrops"] drop_data["id"]: TimedDrop(self, drop_data, claimed_benefits)
for drop_data in data["timeBasedDrops"]
} }
def __repr__(self) -> str: def __repr__(self) -> str:
+36 -7
View File
@@ -9,7 +9,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 (
Callable, Iterable, Optional, Union, List, Dict, Generic, TypeVar, cast, TYPE_CHECKING Optional, Union, List, Dict, Set, Callable, Iterable, Generic, TypeVar, cast, TYPE_CHECKING
) )
try: try:
@@ -697,13 +697,42 @@ class Twitch:
gql_logger.debug(f"GQL Response: {response_json}") gql_logger.debug(f"GQL Response: {response_json}")
return response_json return response_json
async def fetch_inventory(self) -> None: async def fetch_campaign(self, campaign_id: str, claimed_benefits: Set[str]) -> DropsCampaign:
response = await self.gql_request(GQL_OPERATIONS["Inventory"]) response = await self.gql_request(
inventory = response["data"]["currentUser"]["inventory"]["dropCampaignsInProgress"] or [] GQL_OPERATIONS["CampaignDetails"].with_variables(
campaigns = sorted( {"channelLogin": str(self._user_id), "dropID": campaign_id}
(DropsCampaign(self, data) for data in inventory), )
key=lambda c: c.ends_at,
) )
return DropsCampaign(self, response["data"]["user"]["dropCampaign"], claimed_benefits)
async def fetch_inventory(self) -> None:
# fetch all available campaign IDs, that are currently ACTIVE and account is connected
response = await self.gql_request(GQL_OPERATIONS["Campaigns"])
data = response["data"]["currentUser"]["dropCampaigns"] or []
available_campaigns: Set[str] = set(
c["id"] for c in data
if c["status"] == "ACTIVE" and c["self"]["isAccountConnected"]
)
# fetch in-progress campaigns (inventory)
response = await self.gql_request(GQL_OPERATIONS["Inventory"])
inventory = response["data"]["currentUser"]["inventory"]
ongoing_campaigns = inventory["dropCampaignsInProgress"] or []
# this contains claimed benefit edge IDs, not drop IDs
claimed_benefits: Set[str] = set(d["id"] for d in inventory["gameEventDrops"])
campaigns: List[DropsCampaign] = [
DropsCampaign(self, campaign_data, claimed_benefits)
for campaign_data in ongoing_campaigns
]
# filter out in-progress campaigns from all available campaigns,
# since we already have all information needed for them
for campaign in campaigns:
available_campaigns.discard(campaign.id)
# add campaigns that remained, that can be earned but are not in-progress yet
for campaign_id in available_campaigns:
campaign = await self.fetch_campaign(campaign_id, claimed_benefits)
if any(drop.can_earn for drop in campaign.timed_drops.values()):
campaigns.append(campaign)
campaigns.sort(key=lambda c: c.ends_at)
self.inventory = campaigns self.inventory = campaigns
def get_drop(self, drop_id: str) -> Optional[TimedDrop]: def get_drop(self, drop_id: str) -> Optional[TimedDrop]: