diff --git a/gui.py b/gui.py index 3ab2246..49c1b61 100644 --- a/gui.py +++ b/gui.py @@ -8,8 +8,8 @@ from math import log10, ceil from functools import partial from collections import abc, namedtuple from tkinter.font import Font, nametofont -from tkinter import Tk, ttk, StringVar, DoubleVar, IntVar from typing import Any, TypedDict, NoReturn, TYPE_CHECKING +from tkinter import Tk, ttk, StringVar, DoubleVar, IntVar, PhotoImage import pystray from yarl import URL @@ -243,16 +243,18 @@ class MouseOverLabel(ttk.Label): class LinkLabel(ttk.Label): def __init__(self, *args, link: str, **kwargs) -> None: - if "foreground" not in kwargs: - kwargs["foreground"] = "blue" - if "cursor" not in kwargs: - kwargs["cursor"] = "hand2" + self._link: str = link + # style provides font and foreground color if "style" not in kwargs: kwargs["style"] = "Link.TLabel" + elif not kwargs["style"]: + super().__init__(*args, **kwargs) + return + if "cursor" not in kwargs: + kwargs["cursor"] = "hand2" if "padding" not in kwargs: # W, N, E, S kwargs["padding"] = (0, 2, 0, 2) - self._link: str = link super().__init__(*args, **kwargs) self.bind("", self.webopen(self._link)) @@ -271,25 +273,24 @@ class WebsocketStatus: self._topics_var = StringVar(master) frame = ttk.LabelFrame(master, text="Websocket Status", padding=(4, 0, 4, 4)) frame.grid(column=0, row=0, sticky="nsew", padx=2) - monospace_font = Font(frame, family="Courier New", size=10) ttk.Label( frame, text='\n'.join(f"Websocket #{i}:" for i in range(1, MAX_WEBSOCKETS + 1)), - font=monospace_font, + style="MS.TLabel", ).grid(column=0, row=0) ttk.Label( frame, textvariable=self._status_var, width=16, justify="left", - font=monospace_font, + style="MS.TLabel", ).grid(column=1, row=0) ttk.Label( frame, textvariable=self._topics_var, width=(digits * 2 + 1), justify="right", - font=monospace_font, + style="MS.TLabel", ).grid(column=2, row=0) self._items: dict[int, _WSEntry | None] = {i: None for i in range(MAX_WEBSOCKETS)} self._update() @@ -905,14 +906,36 @@ class Notebook: class InventoryOverview: def __init__(self, manager: GUIManager, master: ttk.Widget): self._cache = manager._cache - master.rowconfigure(0, weight=1) - master.columnconfigure(0, weight=1) + self._filters = { + "notlinked": IntVar(master, 0), + "expired": IntVar(master, 1), + "upcoming": IntVar(master, 1), + } + filter_frame = ttk.LabelFrame(master, text="Filter", padding=(4, 0, 4, 4)) + LABEL_SPACING = 20 + filter_frame.grid(column=0, row=0, columnspan=2, sticky="nsew") + ttk.Label(filter_frame, text="Show:", padding=(0, 0, 10, 0)).grid(column=0, row=0) + ttk.Checkbutton(filter_frame, variable=self._filters["notlinked"]).grid(column=1, row=0) + ttk.Label( + filter_frame, text="Not linked", padding=(0, 0, LABEL_SPACING, 0) + ).grid(column=2, row=0) + ttk.Checkbutton(filter_frame, variable=self._filters["expired"]).grid(column=3, row=0) + ttk.Label( + filter_frame, text="Expired", padding=(0, 0, LABEL_SPACING, 0) + ).grid(column=4, row=0) + ttk.Checkbutton(filter_frame, variable=self._filters["upcoming"]).grid(column=5, row=0) + ttk.Label( + filter_frame, text="Upcoming", padding=(0, 0, LABEL_SPACING, 0) + ).grid(column=6, row=0) + ttk.Button(filter_frame, text="Refresh", command=self.refresh).grid(column=7, row=0) self._canvas = tk.Canvas(master, scrollregion=(0, 0, 0, 0)) - self._canvas.grid(column=0, row=0, sticky="nsew") + self._canvas.grid(column=0, row=1, sticky="nsew") + master.rowconfigure(1, weight=1) + master.columnconfigure(0, weight=1) xscroll = ttk.Scrollbar(master, orient="horizontal", command=self._canvas.xview) - xscroll.grid(column=0, row=1, sticky="ew") + xscroll.grid(column=0, row=2, sticky="ew") yscroll = ttk.Scrollbar(master, orient="vertical", command=self._canvas.yview) - yscroll.grid(column=1, row=0, sticky="ns") + yscroll.grid(column=1, row=1, sticky="ns") self._canvas.configure(xscrollcommand=xscroll.set, yscrollcommand=yscroll.set) self._canvas.bind("", self._canvas_update) self._main_frame = ttk.Frame(self._canvas) @@ -921,10 +944,31 @@ class InventoryOverview: ) self._canvas.bind("", lambda e: self._canvas.unbind_all("")) self._canvas.create_window(0, 0, anchor="nw", window=self._main_frame) - self._campaigns: list[DropsCampaign] = [] + self._campaigns: dict[DropsCampaign, ttk.Frame] = {} self._drops: dict[str, ttk.Label] = {} + def _update_visibility(self, campaign: DropsCampaign): + # True if the campaign is supposed to show, False makes it hidden. + frame = self._campaigns[campaign] + if ( + (campaign.linked or self._filters["notlinked"].get()) + and ( + campaign.active + or self._filters["expired"].get() and campaign.expired + or self._filters["upcoming"].get() and campaign.upcoming + ) + ): + frame.grid() + else: + frame.grid_remove() + + def refresh(self): + for campaign in self._campaigns: + self._update_visibility(campaign) + self._canvas_update() + def _canvas_update(self, event: tk.Event[tk.Canvas] | None = None): + self._canvas.update_idletasks() self._canvas.configure(scrollregion=self._canvas.bbox("all")) def _on_mousewheel(self, event: tk.Event[tk.Misc]): @@ -939,13 +983,16 @@ class InventoryOverview: async def add_campaign(self, campaign: DropsCampaign) -> None: campaign_frame = ttk.Frame(self._main_frame, relief="ridge", borderwidth=1, padding=4) campaign_frame.grid(column=0, row=len(self._campaigns), sticky="nsew", pady=3) - self._campaigns.append(campaign) - campaign_frame.rowconfigure(3, weight=1) + self._campaigns[campaign] = campaign_frame + self._update_visibility(campaign) + campaign_frame.rowconfigure(4, weight=1) campaign_frame.columnconfigure(1, weight=1) campaign_frame.columnconfigure(3, weight=10000) + # Name ttk.Label( campaign_frame, text=campaign.name, takefocus=False, width=45 ).grid(column=0, row=0, columnspan=2, sticky="w") + # Status if campaign.active: status_text: str = "Active ✔" status_color: tk._Color = "green" @@ -958,6 +1005,7 @@ class InventoryOverview: ttk.Label( campaign_frame, text=status_text, takefocus=False, foreground=status_color ).grid(column=1, row=1, sticky="w", padx=4) + # Starts / Ends MouseOverLabel( campaign_frame, text=f"Ends: {campaign.ends_at.astimezone().replace(microsecond=0, tzinfo=None)}", @@ -966,6 +1014,26 @@ class InventoryOverview: ), takefocus=False, ).grid(column=1, row=2, sticky="w", padx=4) + # Linking status + if campaign.linked: + link_kwargs = { + "style": '', + "text": "Linked ✔", + "foreground": "green", + } + else: + link_kwargs = { + "text": "Not linked ❌", + "foreground": "red", + } + LinkLabel( + campaign_frame, + link=campaign.link_url, + takefocus=False, + padding=0, + **link_kwargs, + ).grid(column=1, row=3, sticky="w", padx=4) + # ACL channels acl = campaign.allowed_channels if acl: if len(acl) <= 5: @@ -977,24 +1045,29 @@ class InventoryOverview: allowed_text = "All" ttk.Label( campaign_frame, text=f"Allowed channels:\n{allowed_text}", takefocus=False - ).grid(column=1, row=3, sticky="nw", padx=4) - campaign_image = await self._cache.get(campaign.image_url, size=(96, 128)) - ttk.Label(campaign_frame, image=campaign_image).grid(column=0, row=1, rowspan=3) + ).grid(column=1, row=4, sticky="nw", padx=4) + # Image + campaign_image = await self._cache.get(campaign.image_url, size=(108, 144)) + ttk.Label(campaign_frame, image=campaign_image).grid(column=0, row=1, rowspan=4) + # Drops separator ttk.Separator( campaign_frame, orient="vertical", takefocus=False - ).grid(column=2, row=0, rowspan=4, sticky="ns") + ).grid(column=2, row=0, rowspan=5, sticky="ns") + # Drops display drops_row = ttk.Frame(campaign_frame) - drops_row.grid(column=3, row=0, rowspan=4, sticky="nsew", padx=4) + drops_row.grid(column=3, row=0, rowspan=5, sticky="nsew", padx=4) drops_row.rowconfigure(0, weight=1) for i, drop in enumerate(campaign.drops): drop_frame = ttk.Frame(drops_row, relief="ridge", borderwidth=1, padding=5) drop_frame.grid(column=i, row=0, padx=4) benefits_frame = ttk.Frame(drop_frame) benefits_frame.grid(column=0, row=0) - for i, benefit in enumerate(drop.benefits): - benefit_image = await self._cache.get(benefit.image_url, (80, 80)) + benefit_images: list[PhotoImage] = await asyncio.gather( + *(self._cache.get(benefit.image_url, (80, 80)) for benefit in drop.benefits) + ) + for i, benefit, image in zip(range(len(drop.benefits)), drop.benefits, benefit_images): ttk.Label( - benefits_frame, text=benefit.name, image=benefit_image, compound="bottom" + benefits_frame, text=benefit.name, image=image, compound="bottom" ).grid(column=i, row=0, padx=5) progress_text, progress_color = self.get_progress(drop) self._drops[drop.id] = label = ttk.Label( @@ -1037,7 +1110,7 @@ def proxy_validate(entry: PlaceholderEntry, settings: Settings) -> bool: if valid: settings.proxy = url else: - entry.delete(0, "end") + entry.clear() return valid @@ -1445,14 +1518,21 @@ class GUIManager: sublayout[1] = sublayout[1][1]["children"][0] del original[0][1]["children"][1] style.layout("TCheckbutton", original) - # adds a button style with a larger font + # label style - green, yellow and red text + style.configure("green.TLabel", foreground="green") + style.configure("yellow.TLabel", foreground="goldenrod") + style.configure("red.TLabel", foreground="red") + # label style with a monospace font + monospaced_font = Font(root, family="Courier New", size=10) + style.configure("MS.TLabel", font=monospaced_font) + # button style with a larger font large_font = default_font.copy() large_font.config(size=12) style.configure("Large.TButton", font=large_font) - # adds a label style that mimics links + # label style that mimics links link_font = default_font.copy() link_font.config(underline=True) - style.configure("Link.TLabel", font=link_font) + style.configure("Link.TLabel", font=link_font, foreground="blue") # end of style changes root_frame = ttk.Frame(root, padding=8) @@ -1607,6 +1687,9 @@ if __name__ == "__main__": return self._str__(self) return super().__str__() + class HashNamespace(SimpleNamespace): + __hash__ = object.__hash__ # type: ignore + def create_game(id: int, name: str): return StrNamespace(name=name, id=id, _str__=lambda s: s.name) @@ -1664,11 +1747,14 @@ if __name__ == "__main__": benefits = [SimpleNamespace(name=name, image_url=image_url) for name in rewards] mock = SimpleNamespace( id="0", - campaign=SimpleNamespace( + campaign=HashNamespace( name=campaign_name, id="campaign", + expired=False, active=False, upcoming=True, + linked=False, + link_url="https://google.com", image_url="https://static-cdn.jtvnw.net/ttv-boxart/460630-285x380.jpg", allowed_channels=[], starts_at=ref_stamp, diff --git a/inventory.py b/inventory.py index 880f2aa..385e88d 100644 --- a/inventory.py +++ b/inventory.py @@ -212,6 +212,8 @@ class DropsCampaign: self.id: str = data["id"] self.name: str = data["name"] self.game: Game = Game(data["game"]) + self.linked: bool = data["self"]["isAccountConnected"] + self.link_url: str = data["accountLinkURL"] # campaign's image actually comes from the game object # we use regex to get rid of the dimensions part (ex. ".../game_id-285x380.jpg") self.image_url: URLType = remove_dimensions(data["game"]["boxArtURL"]) @@ -279,7 +281,8 @@ class DropsCampaign: def _base_can_earn(self, channel: Channel | None = None) -> bool: return ( - self.active # campaign is active + self.linked # account is connected + and self.active # campaign is active # channel isn't specified, or there's no ACL, or the channel is in the ACL and (channel is None or not self.allowed_channels or channel in self.allowed_channels) ) diff --git a/twitch.py b/twitch.py index fe78e08..a98710c 100644 --- a/twitch.py +++ b/twitch.py @@ -460,17 +460,22 @@ class Twitch: @task_wrapper async def _maintenance_task(self) -> None: # NOTE: this task is started anew / restarted on every inventory fetch - # short sleep to let the application sort out the starting sequence and watching channel - await asyncio.sleep(30) + # sleep until the application sorts out the starting sequence and watching channel + while self._state is State.INVENTORY_FETCH: + await asyncio.sleep(5) # figure out the maximum sleep period - # 1h at max, but can be shorter if there's an upcoming campaign earlier than that + # 1h at max, but can be shorter if there's a campaign state change earlier than that # divide the period into up to two evenly spaced checks (usually ~15-30m) now = datetime.now(timezone.utc) one_hour = timedelta(hours=1) - period = min( - (campaign.starts_at - now for campaign in self.inventory if campaign.starts_at > now), - default=one_hour, - ) + period = timedelta.max + for campaign in self.inventory: + if not campaign.linked: + break + if campaign.starts_at > now and (test_period := campaign.starts_at - now) < period: + period = test_period + elif campaign.ends_at > now and (test_period := campaign.ends_at - now) < period: + period = test_period if period > one_hour: period = one_hour times = ceil(period / timedelta(minutes=30)) @@ -925,21 +930,24 @@ class Twitch: for c in available_list if ( c["status"] in applicable_statuses # that are currently ACTIVE - and c["self"]["isAccountConnected"] # and account is connected and c["id"] not in existing_campaigns # and they aren't in the inventory already ) } # add campaigns that remained, that can be earned but are not in-progress yet - for campaign_id, available_data in available_campaigns.items(): - campaign = await self.fetch_campaign(campaign_id, available_data, claimed_benefits) - if campaign.can_earn(): - campaigns.append(campaign) + fetched_campaigns: list[DropsCampaign] = await asyncio.gather( + *( + self.fetch_campaign(campaign_id, available_data, claimed_benefits) + for campaign_id, available_data in available_campaigns.items() + ) + ) + campaigns.extend(fetched_campaigns) campaigns.sort(key=lambda c: c.ends_at) + campaigns.sort(key=lambda c: not c.linked) self._drops.clear() self.gui.inv.clear() self.inventory.clear() for campaign in campaigns: - self._drops.update((drop.id, drop) for drop in campaign.drops) + self._drops.update({drop.id: drop for drop in campaign.drops}) await self.gui.inv.add_campaign(campaign) self.inventory.append(campaign)