kokopelli 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.3
2
+ Name: kokopelli
3
+ Version: 0.6.0
4
+ Summary: Add your description here
5
+ Author: Marvin8
6
+ Author-email: Marvin8 <marvin8@tuta.io>
7
+ Requires-Dist: async-lru~=2.3.0
8
+ Requires-Dist: httpx~=0.28.1
9
+ Requires-Dist: loguru~=0.7.3
10
+ Requires-Dist: rich~=15.0.0
11
+ Requires-Dist: textual~=8.2.8
12
+ Requires-Dist: typer~=0.26.8
13
+ Requires-Dist: whenever~=0.10.2
14
+ Requires-Python: >=3.11
15
+ Project-URL: Issues, https://codeberg.org/marvin8/kokopelli/issues
16
+ Project-URL: Source, https://codeberg.org/marvin8/kokopelli
17
+ Project-URL: Changelog, https://codeberg.org/marvin8/kokopelli/src/branch/main/CHANGELOG.md
18
+ Project-URL: Documentation, https://kokopelli.marvin8.zone/latest/
@@ -0,0 +1,88 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.10.0,<0.12.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "kokopelli"
7
+ version = "0.6.0"
8
+ description = "Add your description here"
9
+ authors = [
10
+ { name = "Marvin8", email = "marvin8@tuta.io" }
11
+ ]
12
+ requires-python = ">=3.11"
13
+ dependencies = [
14
+ "async-lru~=2.3.0",
15
+ "httpx~=0.28.1",
16
+ "loguru~=0.7.3",
17
+ "rich~=15.0.0",
18
+ "textual~=8.2.8",
19
+ "typer~=0.26.8",
20
+ "whenever~=0.10.2",
21
+ ]
22
+
23
+ [project.urls]
24
+ Issues = "https://codeberg.org/marvin8/kokopelli/issues"
25
+ Source = "https://codeberg.org/marvin8/kokopelli"
26
+ Changelog = "https://codeberg.org/marvin8/kokopelli/src/branch/main/CHANGELOG.md"
27
+ Documentation = "https://kokopelli.marvin8.zone/latest/"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "bump-my-version~=1.4.1",
32
+ "complexipy~=6.0.1",
33
+ "mike~=2.2.0",
34
+ "mkdocs~=1.6.1",
35
+ "mkdocs-material~=9.7.6",
36
+ "nox-uv~=0.8.0",
37
+ "prek~=0.4.8",
38
+ "pytest~=9.1.1",
39
+ "pytest-asyncio~=1.4.0",
40
+ "pytest-cov~=7.1.0",
41
+ "pytest-httpx~=0.36.2",
42
+ "ruff~=0.15.21",
43
+ "ty~=0.0.57",
44
+ "uv~=0.11.28",
45
+ ]
46
+
47
+
48
+ [project.scripts]
49
+ kokopelli = "kokopelli.tui:typer_shim"
50
+
51
+ [tool.bumpversion]
52
+ commit = true
53
+ tag = true
54
+ tag_name = "{new_version}"
55
+ parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
56
+ serialize = ["{major}.{minor}.{patch}"]
57
+ message = ":wrench: bump: version {current_version} → {new_version}"
58
+ pre_commit_hooks = ["uv sync", "git add uv.lock"]
59
+
60
+ [[tool.bumpversion.files]]
61
+ filename = "pyproject.toml"
62
+ search = 'version = "{current_version}"'
63
+ replace = 'version = "{new_version}"'
64
+
65
+
66
+ [tool.complexipy]
67
+ paths = ["src"]
68
+
69
+ [tool.deptry.per_rule_ignores]
70
+ DEP002 = [
71
+ "h11", # Addresses vulnerability in transient dependency GHSA-vqfr-h8mv-ghfj
72
+ "h2", # Addresses vulnerability in transient dependency GHSA-847f-9342-265h
73
+ ]
74
+
75
+ [tool.pysentry.ignore]
76
+ while_no_fix = ["PYSEC-2026-89"] # markdown — unfixable HIGH; transitive via docs toolchain, not in published package
77
+
78
+ [tool.uv]
79
+ constraint-dependencies = [
80
+ "idna>=3.15", # GHSA-65pc-fj4g-8rjx — DoS via long inputs; fix in 3.15
81
+ "pydantic-settings>=2.14.2", # Addresses vulnerability GHSA-4xgf-cpjx-pc3j
82
+ "pygments>=2.20.0", # Addresses vulnerability GHSA-5239-wwwm-4pmq
83
+ ]
84
+
85
+ [tool.zaojun]
86
+ cache = true
87
+ groups = true
88
+ min-age = 7
@@ -0,0 +1 @@
1
+ """Provide cli functions to see recommendations for Movies and TV Shows."""
@@ -0,0 +1,272 @@
1
+ """Definitions and methods for TRAKT API."""
2
+
3
+ import json
4
+ from time import sleep
5
+ from typing import Final
6
+ from typing import NamedTuple
7
+ from typing import TypeAlias
8
+ from typing import TypedDict
9
+
10
+ from async_lru import alru_cache
11
+ from httpx import AsyncClient
12
+ from httpx import Client
13
+ from rich import print
14
+
15
+ from .credentials import CredentialsDict
16
+
17
+
18
+ class MediaType(NamedTuple):
19
+ """Holding literals for use with API, URL buildin and screen display."""
20
+
21
+ api_multi: str
22
+ api_single: str
23
+ tmdb: str
24
+ display: str
25
+
26
+
27
+ class TraktIds(TypedDict, total=False):
28
+ """Trakt media item identifier set."""
29
+
30
+ trakt: int
31
+ slug: str
32
+ imdb: str
33
+ tmdb: int
34
+ tvdb: int
35
+
36
+
37
+ class TraktItem(TypedDict, total=False):
38
+ """A Trakt movie or TV show item as returned by the API."""
39
+
40
+ title: str
41
+ year: int
42
+ ids: TraktIds
43
+ overview: str
44
+ tagline: str
45
+ rating: float
46
+ country: str
47
+ genres: list[str]
48
+ status: str
49
+
50
+
51
+ class WatchedEntry(TypedDict, total=False):
52
+ """A single entry from the watched history endpoint."""
53
+
54
+ movie: TraktItem
55
+ show: TraktItem
56
+ plays: int
57
+ last_watched_at: str
58
+
59
+
60
+ class RatedEntry(TypedDict, total=False):
61
+ """A single entry from the ratings endpoint."""
62
+
63
+ movie: TraktItem
64
+ show: TraktItem
65
+ rating: int
66
+ rated_at: str
67
+
68
+
69
+ class Person(TypedDict, total=False):
70
+ """A person (actor, director, etc.) as returned by the people endpoint."""
71
+
72
+ name: str
73
+
74
+
75
+ class CastMember(TypedDict, total=False):
76
+ """A cast entry linking a person to their character."""
77
+
78
+ person: Person
79
+ character: str
80
+
81
+
82
+ class PeopleResponse(TypedDict, total=False):
83
+ """Response from the people endpoint."""
84
+
85
+ cast: list[CastMember]
86
+
87
+
88
+ class RecommendationsPage(NamedTuple):
89
+ """Paginated result from the recommendations endpoint."""
90
+
91
+ items: list[TraktItem]
92
+ has_more: bool
93
+
94
+
95
+ HeadersDict: TypeAlias = dict[str, str]
96
+ OauthDict: TypeAlias = dict[str, str | None]
97
+
98
+
99
+ class TraktApi:
100
+ """Encapsulation of trakt.tv API interactions."""
101
+
102
+ MOVIES: Final[MediaType] = MediaType(api_multi="movies", api_single="movie", tmdb="movie", display="Movies")
103
+ TVSHOWS: Final[MediaType] = MediaType(api_multi="shows", api_single="show", tmdb="tv", display="TV Shows")
104
+
105
+ BASE_URL: Final = "https://trakt.tv"
106
+ API_BASE: Final = "https://api.trakt.tv/"
107
+
108
+ def __init__(self, headers: HeadersDict, limit: int = 20) -> None:
109
+ """Initialise the API client with Trakt authentication headers."""
110
+ self.headers = headers
111
+ self.limit = limit
112
+ self.client = AsyncClient(timeout=30.0)
113
+
114
+ async def close(self) -> None:
115
+ """Close the underlying HTTP client."""
116
+ await self.client.aclose()
117
+
118
+ @alru_cache
119
+ async def get_watched(self, item_type: MediaType) -> list[WatchedEntry]:
120
+ """Get watched items of item_type."""
121
+ extended_info: str = "?extended=full"
122
+ if item_type == TraktApi.TVSHOWS:
123
+ extended_info += ",noseasons"
124
+ response = await self.client.get(
125
+ f"{TraktApi.API_BASE}/sync/watched/{item_type.api_multi}{extended_info}", headers=self.headers
126
+ )
127
+ response.raise_for_status()
128
+ return response.json()
129
+
130
+ @alru_cache
131
+ async def get_rated(self, item_type: MediaType) -> list[RatedEntry]:
132
+ """Get rated items of item_type."""
133
+ response = await self.client.get(
134
+ f"{TraktApi.API_BASE}/sync/ratings/{item_type.api_multi}", headers=self.headers
135
+ )
136
+ response.raise_for_status()
137
+ return response.json()
138
+
139
+ @alru_cache
140
+ async def get_recommendations(self, item_type: MediaType, page: int = 1) -> RecommendationsPage:
141
+ """Retrieve recommendations for the given page using a growing limit."""
142
+ total_limit = self.limit * page
143
+ response = await self.client.get(
144
+ f"{TraktApi.API_BASE}recommendations/{item_type.api_multi}?extended=full&limit={total_limit}",
145
+ headers=self.headers,
146
+ )
147
+ response.raise_for_status()
148
+ all_items: list[TraktItem] = response.json()
149
+ offset = self.limit * (page - 1)
150
+ page_items = all_items[offset : offset + self.limit]
151
+ has_more = len(all_items) == total_limit
152
+ return RecommendationsPage(items=page_items, has_more=has_more)
153
+
154
+ def invalidate_recommendations_cache(self) -> None:
155
+ """Clear the recommendations cache so the next fetch re-requests from the API."""
156
+ TraktApi.get_recommendations.cache_clear()
157
+
158
+ def invalidate_watched_cache(self) -> None:
159
+ """Clear the watched cache after add_seen mutations."""
160
+ TraktApi.get_watched.cache_clear()
161
+
162
+ def invalidate_rated_cache(self) -> None:
163
+ """Clear the ratings cache after rate_item mutations."""
164
+ TraktApi.get_rated.cache_clear()
165
+
166
+ async def add_seen(self, data: str, remove: bool = False) -> None:
167
+ """Add tv shows and movies to seen history."""
168
+ response = await self.client.post(
169
+ f"{TraktApi.API_BASE}sync/history{'/remove' if remove else ''}", content=data, headers=self.headers
170
+ )
171
+ response.raise_for_status()
172
+
173
+ @alru_cache
174
+ async def lookup_by_id(self, trakt_id: str, item_type: MediaType) -> TraktItem:
175
+ """Lookup an item by their TRAKT id."""
176
+ url = f"{TraktApi.API_BASE}search/trakt/{trakt_id}?type={item_type.api_single}&extended=full"
177
+ response = await self.client.get(url=url, headers=self.headers)
178
+ response.raise_for_status()
179
+ item = response.json()[0]
180
+ return item[item_type.api_single]
181
+
182
+ @alru_cache
183
+ async def get_people(self, trakt_id: str, item_type: MediaType) -> PeopleResponse:
184
+ """Retrieve list of people involved with a movie or show."""
185
+ url = f"{TraktApi.API_BASE}/{item_type.api_multi}/{trakt_id}/people"
186
+ response = await self.client.get(url=url, headers=self.headers)
187
+ response.raise_for_status()
188
+ return response.json()
189
+
190
+ async def hide_item(self, trakt_id: str, item_type: MediaType) -> None:
191
+ """Hides a trakt item from recommendations."""
192
+ response = await self.client.delete(
193
+ f"{TraktApi.API_BASE}recommendations/{item_type.api_multi}/{trakt_id}", headers=self.headers
194
+ )
195
+ response.raise_for_status()
196
+
197
+ async def rate_item(self, data: str) -> None:
198
+ """Send rating for a movie or show to Trakt.tv."""
199
+ response = await self.client.post(f"{TraktApi.API_BASE}sync/ratings", content=data, headers=self.headers)
200
+ response.raise_for_status()
201
+
202
+
203
+ def get_access_token(oauth: OauthDict) -> CredentialsDict:
204
+ """Run the device-code OAuth flow and return the resulting credentials."""
205
+ headers: dict[str, str] = {
206
+ "Content-Type": "application/json",
207
+ "trakt-api-version": "2",
208
+ "trakt-api-key": str(oauth.get("client_id") or ""),
209
+ }
210
+ with Client() as client:
211
+ data = json.dumps({"client_id": oauth.get("client_id", "")})
212
+ response = client.post(f"{TraktApi.API_BASE}oauth/device/code", content=data, headers=headers)
213
+ response.raise_for_status()
214
+ device_code = response.json()
215
+ print(f"Please go to {device_code.get('verification_url')}/{device_code.get('user_code')}")
216
+ interval = device_code.get("interval")
217
+ time_left = device_code.get("expires_in")
218
+ sleep(interval)
219
+ time_left -= interval
220
+ token_data = json.dumps(
221
+ {
222
+ "code": device_code.get("device_code"),
223
+ "client_id": oauth.get("client_id"),
224
+ "client_secret": oauth.get("client_secret"),
225
+ }
226
+ )
227
+ while time_left > 0:
228
+ response = client.post(f"{TraktApi.API_BASE}oauth/device/token", content=token_data, headers=headers)
229
+ if response.status_code == 200:
230
+ break
231
+ sleep(interval)
232
+ time_left -= interval
233
+
234
+ device_token = response.json()
235
+ return CredentialsDict(
236
+ client_id=str(oauth.get("client_id") or ""),
237
+ client_secret=str(oauth.get("client_secret") or ""),
238
+ access_token=str(device_token.get("access_token", "")),
239
+ refresh_token=str(device_token.get("refresh_token", "")),
240
+ expires_in=int(device_token.get("expires_in", 0)),
241
+ created_at=int(device_token.get("created_at", 0)),
242
+ )
243
+
244
+
245
+ def renew_access_token(oauth: OauthDict) -> CredentialsDict:
246
+ """Refresh the access token using the refresh_token and return updated credentials."""
247
+ headers: dict[str, str] = {
248
+ "Content-Type": "application/json",
249
+ "trakt-api-version": "2",
250
+ "trakt-api-key": str(oauth.get("client_id") or ""),
251
+ }
252
+ data = json.dumps(
253
+ {
254
+ "refresh_token": oauth.get("refresh_token"),
255
+ "client_id": oauth.get("client_id"),
256
+ "client_secret": oauth.get("client_secret"),
257
+ "grant_type": "refresh_token",
258
+ }
259
+ )
260
+
261
+ with Client() as client:
262
+ response = client.post(f"{TraktApi.API_BASE}oauth/token", headers=headers, content=data)
263
+
264
+ tokens = response.json()
265
+ return CredentialsDict(
266
+ client_id=str(oauth.get("client_id") or ""),
267
+ client_secret=str(oauth.get("client_secret") or ""),
268
+ access_token=str(tokens.get("access_token", "")),
269
+ refresh_token=str(tokens.get("refresh_token", "")),
270
+ expires_in=int(tokens.get("expires_in", 0)),
271
+ created_at=int(tokens.get("created_at", 0)),
272
+ )
@@ -0,0 +1,76 @@
1
+ """Credential storage for kokopelli."""
2
+
3
+ import os
4
+ import tomllib
5
+ from pathlib import Path
6
+ from typing import Final
7
+ from typing import TypedDict
8
+
9
+
10
+ class CredentialsDict(TypedDict, total=False):
11
+ """Trakt.tv OAuth credentials."""
12
+
13
+ client_id: str
14
+ client_secret: str
15
+ access_token: str
16
+ refresh_token: str
17
+ expires_in: int
18
+ created_at: int
19
+
20
+
21
+ CREDENTIALS_DIR: Final[Path] = (
22
+ Path(os.environ["XDG_CONFIG_HOME"]) / "kokopelli"
23
+ if "XDG_CONFIG_HOME" in os.environ
24
+ else Path.home() / ".config" / "kokopelli"
25
+ )
26
+ CREDENTIALS_PATH: Final[Path] = CREDENTIALS_DIR / "credentials.toml"
27
+
28
+ _ENVRC_KEY_MAP: Final[dict[str, str]] = {
29
+ "TRAKT_CLIENT_ID": "client_id",
30
+ "TRAKT_CLIENT_SECRET": "client_secret",
31
+ "TRAKT_ACCESS_TOKEN": "access_token",
32
+ "TRAKT_REFRESH_TOKEN": "refresh_token",
33
+ "TRAKT_TOKEN_EXPIRES_IN": "expires_in",
34
+ "TRAKT_TOKEN_CREATED_AT": "created_at",
35
+ }
36
+
37
+ _INT_FIELDS: Final[frozenset[str]] = frozenset({"expires_in", "created_at"})
38
+
39
+
40
+ def read_credentials() -> CredentialsDict:
41
+ """Read credentials from the XDG config file."""
42
+ if not CREDENTIALS_PATH.exists():
43
+ return CredentialsDict()
44
+ with CREDENTIALS_PATH.open("rb") as f:
45
+ data = tomllib.load(f)
46
+ return CredentialsDict(**data.get("credentials", {}))
47
+
48
+
49
+ def write_credentials(creds: CredentialsDict) -> None:
50
+ """Write credentials to the XDG config file with owner-only permissions."""
51
+ CREDENTIALS_DIR.mkdir(parents=True, exist_ok=True)
52
+ lines = ["[credentials]\n"]
53
+ for key, value in creds.items():
54
+ if isinstance(value, int):
55
+ lines.append(f"{key} = {value}\n")
56
+ else:
57
+ lines.append(f'{key} = "{value}"\n')
58
+ CREDENTIALS_PATH.write_text("".join(lines), encoding="utf-8")
59
+ CREDENTIALS_PATH.chmod(0o600)
60
+
61
+
62
+ def migrate_from_envrc(envrc_path: Path) -> CredentialsDict | None:
63
+ """Parse an .envrc file and return a CredentialsDict if credentials are found."""
64
+ if not envrc_path.exists():
65
+ return None
66
+ raw: dict[str, str | int] = {}
67
+ for line in envrc_path.read_text(encoding="utf-8").splitlines():
68
+ if not line.startswith("export "):
69
+ continue
70
+ _, _, assignment = line.partition(" ")
71
+ key, _, value = assignment.partition("=")
72
+ mapped = _ENVRC_KEY_MAP.get(key)
73
+ if mapped is None:
74
+ continue
75
+ raw[mapped] = int(value) if mapped in _INT_FIELDS else value
76
+ return CredentialsDict(**raw) if raw else None
@@ -0,0 +1,771 @@
1
+ """Methods to control tui."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import sys
7
+ import webbrowser
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+ from typing import ClassVar
11
+
12
+ import httpx
13
+ import typer
14
+ from loguru import logger as log
15
+
16
+ if TYPE_CHECKING:
17
+ from loguru import Logger
18
+ from loguru import Record
19
+ from textual.app import App
20
+ from textual.app import ComposeResult
21
+ from textual.containers import Container
22
+ from textual.containers import Horizontal
23
+ from textual.containers import Vertical
24
+ from textual.screen import ModalScreen
25
+ from textual.widgets import Button
26
+ from textual.widgets import DataTable
27
+ from textual.widgets import Footer
28
+ from textual.widgets import Header
29
+ from textual.widgets import Label
30
+ from textual.widgets import LoadingIndicator
31
+ from textual.widgets import TextArea
32
+ from textual.widgets.data_table import RowKey
33
+ from whenever import Instant
34
+
35
+ from .api_actions import HeadersDict
36
+ from .api_actions import MediaType
37
+ from .api_actions import OauthDict
38
+ from .api_actions import RecommendationsPage
39
+ from .api_actions import TraktApi
40
+ from .api_actions import TraktItem
41
+ from .api_actions import get_access_token
42
+ from .api_actions import renew_access_token
43
+ from .credentials import CREDENTIALS_PATH
44
+ from .credentials import CredentialsDict
45
+ from .credentials import migrate_from_envrc
46
+ from .credentials import read_credentials
47
+ from .credentials import write_credentials
48
+
49
+ LOG_DIR: Path = (
50
+ Path(os.environ["XDG_STATE_HOME"]) / "kokopelli"
51
+ if "XDG_STATE_HOME" in os.environ
52
+ else Path.home() / ".local" / "state" / "kokopelli"
53
+ )
54
+ LOG_PATH: Path = LOG_DIR / "trakt.log"
55
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
56
+
57
+
58
+ def _log_format(record: "Record") -> str:
59
+ """Render extra fields as key=value pairs; omit the section when empty."""
60
+ extra = record["extra"]
61
+ extra_part = " | " + " ".join(f"{k}={v!r}" for k, v in sorted(extra.items())) if extra else ""
62
+ base = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level:<8} | {name}:{function}:{line}"
63
+ return base + extra_part + " | {message}\n{exception}"
64
+
65
+
66
+ log.remove()
67
+ log.add(sink=LOG_PATH, level="DEBUG", format=_log_format, colorize=False)
68
+ log.add(sink=sys.stdout, level="ERROR", format=_log_format, colorize=False)
69
+
70
+
71
+ def _error_message(exc: httpx.HTTPError) -> str:
72
+ """Return a human-readable message for an httpx error."""
73
+ if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 401:
74
+ return "Token expired — run `tui --configure`"
75
+ if isinstance(exc, httpx.ConnectError):
76
+ return "Network error — check your connection and retry"
77
+ if isinstance(exc, httpx.TimeoutException):
78
+ return "Request timed out — retry"
79
+ return str(exc)
80
+
81
+
82
+ class LoadingScreen(ModalScreen):
83
+ """Loading screen to be usd while retrieving info from trakt."""
84
+
85
+ DEFAULT_CSS = """
86
+ LoadingScreen {
87
+ align: center middle;
88
+ }
89
+
90
+ LoadingScreen > Container {
91
+ min-width: 12;
92
+ min-height: 3;
93
+ border: thick $accent;
94
+ background: $surface;
95
+ padding: 1;
96
+ }"""
97
+
98
+ def compose(self) -> ComposeResult:
99
+ """Assemble widgets."""
100
+ with Container():
101
+ yield LoadingIndicator()
102
+
103
+
104
+ class RatingsScreen(ModalScreen):
105
+ """Define modal screen to ask for rating."""
106
+
107
+ DEFAULT_CSS = """
108
+ RatingsScreen {
109
+ align: center middle;
110
+ }
111
+
112
+ RatingsScreen > Container {
113
+ width: auto;
114
+ height: auto;
115
+ border: thick $background 80%;
116
+ background: $surface;
117
+ }
118
+
119
+ RatingsScreen > Container > Label {
120
+ width: 100%;
121
+ content-align-horizontal: center;
122
+ margin-top: 1;
123
+ }
124
+
125
+ RatingsScreen > Container > Horizontal {
126
+ width: auto;
127
+ height: auto;
128
+ }
129
+
130
+ RatingsScreen > Container > Horizontal > Button {
131
+ margin: 2 4;
132
+ }
133
+
134
+ RatingsScreen > Container > Button {
135
+ margin: 2 4;
136
+ }
137
+ """
138
+
139
+ def compose(self) -> ComposeResult:
140
+ """Assemble Widgets."""
141
+ with Container():
142
+ yield Label("How would you rate this?", id="rating")
143
+ with Horizontal():
144
+ yield Button("Weak Sauce :(", id="r1")
145
+ yield Button("Terrible", id="r2")
146
+ yield Button("Bad", id="r3")
147
+ yield Button("Poor", id="r4")
148
+ yield Button("Meh", id="r5")
149
+ with Horizontal():
150
+ yield Button("Fair", id="r6")
151
+ yield Button("Good", id="r7")
152
+ yield Button("Great", id="r8")
153
+ yield Button("Superb", id="r9")
154
+ yield Button("Totally Ninja!", id="r10")
155
+ yield Button("Cancel", id="cancel")
156
+
157
+ def on_button_pressed(self, event: Button.Pressed) -> None:
158
+ """Process button press."""
159
+ if event.button.id == "cancel":
160
+ self.dismiss()
161
+
162
+ self.dismiss(str(event.button.id))
163
+
164
+
165
+ class RecommendApp(App):
166
+ """Define behaviour of recommendations app."""
167
+
168
+ BINDINGS: ClassVar = [
169
+ ("m", "recommend_movies", "Recommend Movies"),
170
+ ("M", "unrated_watched_movies", "Rate watched movies"),
171
+ ("t", "recommend_shows", "Recommend TV-Shows"),
172
+ ("T", "unrated_watched_shows", "Rate watched TV-Shows"),
173
+ ("i", "open_info", "Open Info in Browser"),
174
+ ("I", "open_in_tmdb", "Open TMDB in Browser"),
175
+ ("r", "rate_seen", "Rate highlighted and mark as seen"),
176
+ ("R", "rate_hide", "Rate highlighted and hide"),
177
+ ("s", "seen", "Mark highlighted as Seen"),
178
+ ("h", "hide", "Hide highlighted"),
179
+ ("x", "exit", "Close app and exit"),
180
+ ]
181
+
182
+ def __init__(self, headers: HeadersDict, unrated_limit: int = 20):
183
+ """Initialise the app with Trakt API headers and default widget state."""
184
+ super().__init__()
185
+ self.api = TraktApi(headers=headers, limit=unrated_limit)
186
+ self.item_type = TraktApi.MOVIES
187
+ self.details = TextArea(read_only=True, name="Details")
188
+ self.actors: DataTable = DataTable(name="Actors", show_cursor=False)
189
+ self.items: DataTable = DataTable(cursor_type="row", name="items")
190
+ self.title = "Recommendations"
191
+ self.sub_title = self.item_type.display
192
+ self.row_key_highlighted: str | None = None
193
+ self.info: list[TraktItem] = []
194
+ self._bg_tasks: list[asyncio.Task[None]] = []
195
+ self.unrated_limit: int = unrated_limit
196
+ self.recommendation_page: int = 1
197
+ self._is_paginated_view: bool = True
198
+
199
+ def compose(self) -> ComposeResult:
200
+ """Assemble Widgets."""
201
+ yield Header()
202
+ with Vertical():
203
+ yield self.items
204
+ with Horizontal():
205
+ yield self.details
206
+ yield self.actors
207
+ yield Footer()
208
+
209
+ def _item_from_row_key(self, row_key: str) -> TraktItem | None:
210
+ """Return the TraktItem from self.info matching the given trakt ID string."""
211
+ for item in self.info:
212
+ if str((item.get("ids") or {}).get("trakt", "")) == row_key:
213
+ return item
214
+ return None
215
+
216
+ async def on_unmount(self) -> None:
217
+ """Drain in-flight background tasks then close the HTTP client."""
218
+ pending = [t for t in self._bg_tasks if not t.done()]
219
+ if pending:
220
+ await asyncio.gather(*pending, return_exceptions=True)
221
+ await self.api.close()
222
+
223
+ async def on_mount(self) -> None:
224
+ """Populate Movie Recommendations."""
225
+ self.items.add_columns("Title", "Year", "Rating", "Country")
226
+ self.actors.add_columns("Actor", "Role")
227
+ await self.push_screen(LoadingScreen())
228
+ await self.load_recommendations()
229
+ await self.pop_screen()
230
+ self._bg_tasks.append(asyncio.create_task(self._prewarm_caches()))
231
+
232
+ async def _populate_items_table(self, info: list[TraktItem], has_more: bool = False) -> None:
233
+ """Populate the items DataTable from a list of TraktItems."""
234
+ _log = log.bind(phase="populate_table", item_type=self.item_type.display)
235
+ self.info = info
236
+ self.sub_title = self.item_type.display
237
+ self.row_key_highlighted = None
238
+ self.items.clear()
239
+ row_keys: list[str | None] = []
240
+ for item in self.info:
241
+ ids = item.get("ids") or {}
242
+ trakt_id = ids.get("trakt")
243
+ trakt_id_str = str(trakt_id) if trakt_id is not None else None
244
+ rating = f"{float(item.get('rating') or 0.0):.2f}"
245
+ self.items.add_row(item.get("title"), item.get("year"), rating, item.get("country"), key=trakt_id_str)
246
+ row_keys.append(trakt_id_str)
247
+ if has_more:
248
+ self.items.add_row("...", None, None, None, key="__more__")
249
+ _log.debug(f"Table populated: {len(info)} items, row_keys={row_keys}")
250
+ if self.info:
251
+ self.items.move_cursor(row=0)
252
+ self._bg_tasks.append(asyncio.create_task(self._prefetch_people()))
253
+
254
+ async def _prefetch_people(self) -> None:
255
+ """Warm the people cache for all currently displayed items in parallel."""
256
+ _log = log.bind(phase="prefetch_people", item_type=self.item_type.display)
257
+ ids: list[str] = []
258
+ for item in self.info:
259
+ item_ids = item.get("ids") or {}
260
+ trakt_id = item_ids.get("trakt")
261
+ if trakt_id is not None:
262
+ ids.append(str(trakt_id))
263
+ _log.debug(f"Prefetching people for {len(ids)} items")
264
+ results = await asyncio.gather(
265
+ *(self.api.get_people(trakt_id=tid, item_type=self.item_type) for tid in ids),
266
+ return_exceptions=True,
267
+ )
268
+ errors = [r for r in results if isinstance(r, BaseException)]
269
+ if errors:
270
+ _log.warning(f"{len(errors)} people fetch(es) failed: {errors[0]}")
271
+ else:
272
+ _log.debug("People prefetch complete")
273
+
274
+ async def _prewarm_unrated(self, item_type: MediaType) -> None:
275
+ """Warm watched/rated/lookup caches for one item type."""
276
+ watched = await self.api.get_watched(item_type)
277
+ rated = await self.api.get_rated(item_type)
278
+ is_movie = item_type == TraktApi.MOVIES
279
+ rated_ids = self._collect_rated_ids(rated, is_movie)
280
+ await self._collect_unrated_items(watched, rated_ids, is_movie, item_type)
281
+
282
+ async def _prewarm_caches(self) -> None:
283
+ """Pre-fetch all non-current views into alru_cache in the background."""
284
+ _log = log.bind(phase="prewarm_caches")
285
+ _log.debug("Starting background cache pre-warm")
286
+ await asyncio.gather(
287
+ self.api.get_recommendations(TraktApi.TVSHOWS),
288
+ self._prewarm_unrated(TraktApi.MOVIES),
289
+ self._prewarm_unrated(TraktApi.TVSHOWS),
290
+ return_exceptions=True,
291
+ )
292
+ _log.debug("Cache pre-warm complete")
293
+
294
+ async def load_recommendations(self):
295
+ """Load recommendations from Trakt.tv."""
296
+ _log = log.bind(phase="load_recommendations", item_type=self.item_type.display)
297
+ _log.debug(f"Fetching recommendations page={self.recommendation_page}")
298
+ page_result: RecommendationsPage = await self.api.get_recommendations(
299
+ item_type=self.item_type, page=self.recommendation_page
300
+ )
301
+ _log.debug(f"Got {len(page_result.items)} recommendations has_more={page_result.has_more}")
302
+ await self._populate_items_table(page_result.items, has_more=page_result.has_more)
303
+
304
+ async def _refresh_details(self, row_key: str) -> None:
305
+ """Update the details text and actors table for the given trakt ID string."""
306
+ _log = log.bind(phase="refresh_details", row_key=row_key)
307
+ self.row_key_highlighted = row_key
308
+ display_item: TraktItem | None = self._item_from_row_key(row_key)
309
+ _log.debug(f"title={display_item.get('title')!r}" if display_item else "item not found in self.info")
310
+ detail_text = ""
311
+ if display_item:
312
+ tagline = display_item.get("tagline")
313
+ detail_text += tagline if tagline else ""
314
+ detail_text += "\n\n"
315
+ detail_text += f"{', '.join(display_item.get('genres') or [])}"
316
+ detail_text += "\n\n"
317
+ detail_text += display_item.get("overview") or ""
318
+ ids = display_item.get("ids") or {}
319
+ slug = ids.get("slug")
320
+ detail_text += f"\n\n{TraktApi.BASE_URL}/{self.item_type.api_multi}/{slug}" if slug else ""
321
+ detail_text += "\n\n"
322
+ detail_text += f"Status: {display_item.get('status', '')}"
323
+ self.details.text = detail_text
324
+ people = await self.api.get_people(trakt_id=row_key, item_type=self.item_type)
325
+ cast = people.get("cast") or []
326
+ self.actors.clear()
327
+ for actor in cast[:10]:
328
+ person = actor.get("person")
329
+ self.actors.add_row(person.get("name") if person else None, actor.get("character"))
330
+
331
+ async def on_data_table_row_highlighted(self, message: DataTable.RowHighlighted) -> None:
332
+ """Update Details widget when cursor has moved on recommendations list."""
333
+ if message.row_key is None or not message.row_key.value:
334
+ return
335
+ if message.row_key.value == "__more__":
336
+ self.row_key_highlighted = None
337
+ return
338
+ await self._refresh_details(message.row_key.value)
339
+
340
+ async def on_data_table_row_selected(self, message: DataTable.RowSelected) -> None:
341
+ """Load the next recommendations page when Enter is pressed on the sentinel row."""
342
+ if message.row_key.value == "__more__" and self._is_paginated_view:
343
+ self.recommendation_page += 1
344
+ await self.push_screen(LoadingScreen())
345
+ await self.load_recommendations()
346
+ await self.pop_screen()
347
+
348
+ async def action_recommend_movies(self) -> None:
349
+ """Load movie recommendations."""
350
+ await self.app.push_screen(LoadingScreen())
351
+
352
+ self.item_type = TraktApi.MOVIES
353
+ self.recommendation_page = 1
354
+ self._is_paginated_view = True
355
+ await self.load_recommendations()
356
+
357
+ await self.app.pop_screen()
358
+
359
+ async def action_unrated_watched_movies(self) -> None:
360
+ """Load watched movies that have not been rated."""
361
+ await self.app.push_screen(LoadingScreen())
362
+
363
+ self.item_type = TraktApi.MOVIES
364
+ self._is_paginated_view = False
365
+ await self.load_unrated_watched()
366
+
367
+ await self.app.pop_screen()
368
+
369
+ async def action_unrated_watched_shows(self) -> None:
370
+ """Load watched tv shows that have not been rated."""
371
+ await self.app.push_screen(LoadingScreen())
372
+
373
+ self.item_type = TraktApi.TVSHOWS
374
+ self._is_paginated_view = False
375
+ await self.load_unrated_watched()
376
+
377
+ await self.app.pop_screen()
378
+
379
+ def _collect_rated_ids(self, rated_items: list, is_movie: bool) -> set[int]:
380
+ """Extract the set of rated trakt IDs from a rated-items list."""
381
+ rated_ids: set[int] = set()
382
+ for rated_entry in rated_items:
383
+ rated_item = rated_entry.get("movie") if is_movie else rated_entry.get("show")
384
+ if rated_item:
385
+ ids = rated_item.get("ids") or {}
386
+ trakt_id = ids.get("trakt")
387
+ if trakt_id is not None:
388
+ rated_ids.add(trakt_id)
389
+ return rated_ids
390
+
391
+ def _extract_unrated_ids(self, watched_items: list, rated_ids: set[int], is_movie: bool) -> tuple[list[str], bool]:
392
+ """Collect up to self.unrated_limit unrated trakt IDs; also report whether more exist."""
393
+ unrated_ids: list[str] = []
394
+ seen_ids: set[int] = set()
395
+ for entry in watched_items:
396
+ item = entry.get("movie") if is_movie else entry.get("show")
397
+ if not item:
398
+ continue
399
+ ids = item.get("ids") or {}
400
+ trakt_id = ids.get("trakt")
401
+ if trakt_id is None or trakt_id in seen_ids:
402
+ continue
403
+ if trakt_id not in rated_ids:
404
+ seen_ids.add(trakt_id)
405
+ unrated_ids.append(str(trakt_id))
406
+ if len(unrated_ids) > self.unrated_limit:
407
+ return unrated_ids[: self.unrated_limit], True
408
+ return unrated_ids, False
409
+
410
+ async def _collect_unrated_items(
411
+ self, watched_items: list, rated_ids: set[int], is_movie: bool, item_type: MediaType
412
+ ) -> tuple[list[TraktItem], bool]:
413
+ """Filter watched items to those not yet rated, up to self.unrated_limit items."""
414
+ unrated_ids, has_more = self._extract_unrated_ids(watched_items, rated_ids, is_movie)
415
+ results = await asyncio.gather(
416
+ *(self.api.lookup_by_id(trakt_id=tid, item_type=item_type) for tid in unrated_ids),
417
+ return_exceptions=True,
418
+ )
419
+ errors = [r for r in results if isinstance(r, BaseException)]
420
+ if errors:
421
+ log.bind(phase="collect_unrated_items", item_type=item_type.display).warning(
422
+ f"{len(errors)} lookup(s) failed: {errors[0]}"
423
+ )
424
+ return [r for r in results if isinstance(r, dict)], has_more
425
+
426
+ async def load_unrated_watched(self):
427
+ """Load unrated but watched items (movies or tv-shows)."""
428
+ _log = log.bind(phase="load_unrated_watched", item_type=self.item_type.display)
429
+ _log.debug("Fetching watched and rated lists")
430
+ watched_items = await self.api.get_watched(item_type=self.item_type)
431
+ rated_items = await self.api.get_rated(item_type=self.item_type)
432
+ _log.debug(f"{len(watched_items)=} {len(rated_items)=}")
433
+ is_movie = self.item_type == TraktApi.MOVIES
434
+ rated_ids = self._collect_rated_ids(rated_items, is_movie)
435
+ unrated_items, has_more = await self._collect_unrated_items(watched_items, rated_ids, is_movie, self.item_type)
436
+ _log.debug(f"{len(unrated_items)=}")
437
+ await self._populate_items_table(unrated_items, has_more=has_more)
438
+
439
+ async def action_recommend_shows(self) -> None:
440
+ """Load tv show recommendations."""
441
+ await self.app.push_screen(LoadingScreen())
442
+
443
+ self.item_type = TraktApi.TVSHOWS
444
+ self.recommendation_page = 1
445
+ self._is_paginated_view = True
446
+ await self.load_recommendations()
447
+
448
+ await self.app.pop_screen()
449
+
450
+ async def _remove_item_optimistically(self, row_key: str) -> None:
451
+ """Remove item from self.info and table; clear panels then repopulate for next row."""
452
+ self.info = [item for item in self.info if str((item.get("ids") or {}).get("trakt", "")) != row_key]
453
+ try:
454
+ self.items.remove_row(row_key=RowKey(row_key))
455
+ except Exception as exc:
456
+ log.bind(phase="remove_optimistic", row_key=row_key).error(f"Could not remove row: {exc}")
457
+ self.details.text = ""
458
+ self.actors.clear()
459
+ if self.items.row_count > 0:
460
+ new_key = self.items.ordered_rows[self.items.cursor_row].key.value
461
+ if new_key:
462
+ await self._refresh_details(new_key)
463
+ else:
464
+ self.row_key_highlighted = None
465
+
466
+ async def action_seen(self) -> None:
467
+ """Mark highlighted item as seen."""
468
+ if not self.row_key_highlighted:
469
+ return
470
+ row_key = self.row_key_highlighted
471
+ item_type = self.item_type
472
+ item = self._item_from_row_key(row_key)
473
+ if not item:
474
+ log.bind(phase="action_seen", row_key=row_key).warning("row_key not found in self.info")
475
+ return
476
+ data = json.dumps({item_type.api_multi: [item]})
477
+ self.api.invalidate_recommendations_cache()
478
+ self.api.invalidate_watched_cache()
479
+ await self._remove_item_optimistically(row_key)
480
+ self._bg_tasks.append(asyncio.create_task(self._add_seen_bg(data, row_key)))
481
+
482
+ async def _add_seen_bg(self, data: str, row_key: str) -> None:
483
+ """Send the add-to-history request to Trakt; notify on failure."""
484
+ _log = log.bind(phase="action_seen", row_key=row_key)
485
+ try:
486
+ await self.api.add_seen(data=data)
487
+ _log.debug("Marked as seen")
488
+ except httpx.HTTPError as exc:
489
+ _log.error(f"Network error marking seen: {exc}")
490
+ self.notify(f"Mark as seen failed: {_error_message(exc)}", severity="error")
491
+
492
+ async def action_hide(self) -> None:
493
+ """Hide item from recommendations."""
494
+ if not self.row_key_highlighted:
495
+ return
496
+ row_key = self.row_key_highlighted
497
+ item_type = self.item_type
498
+ self.api.invalidate_recommendations_cache()
499
+ await self._remove_item_optimistically(row_key)
500
+ self._bg_tasks.append(asyncio.create_task(self._hide_item_bg(row_key, item_type)))
501
+
502
+ async def _hide_item_bg(self, row_key: str, item_type: MediaType) -> None:
503
+ """Send the hide request to Trakt; notify on failure."""
504
+ _log = log.bind(phase="action_hide", row_key=row_key)
505
+ try:
506
+ await self.api.hide_item(trakt_id=row_key, item_type=item_type)
507
+ _log.debug("Hidden")
508
+ except httpx.HTTPError as exc:
509
+ _log.error(f"Network error hiding item: {exc}")
510
+ self.notify(f"Hide failed: {_error_message(exc)}", severity="error")
511
+
512
+ async def _do_rate_seen(self, row_key: str, rating: str) -> None:
513
+ """Optimistically remove item, then rate+seen in the background."""
514
+ _log = log.bind(phase="action_rate_seen", row_key=row_key, rating=rating)
515
+ source_item = self._item_from_row_key(row_key)
516
+ if not source_item:
517
+ _log.warning("row_key not found in self.info — cannot rate+seen")
518
+ return
519
+ item = dict(source_item)
520
+ item["rating"] = float(rating[1:])
521
+ _log.debug(f"Rating+seen: {item.get('title')!r} score={item.get('rating')}")
522
+ data = json.dumps({self.item_type.api_multi: [item]})
523
+ self.api.invalidate_rated_cache()
524
+ self.api.invalidate_watched_cache()
525
+ await self._remove_item_optimistically(row_key)
526
+ self._bg_tasks.append(asyncio.create_task(self._rate_seen_bg(data, _log)))
527
+
528
+ async def _rate_seen_bg(self, data: str, _log: "Logger") -> None:
529
+ """Send rate and add-to-history requests concurrently; notify on failure."""
530
+ try:
531
+ await asyncio.gather(self.api.rate_item(data=data), self.api.add_seen(data=data))
532
+ _log.debug("Rate+seen complete")
533
+ except httpx.HTTPError as exc:
534
+ _log.error(f"Network error rating+seen: {exc}")
535
+ self.notify(f"Rating failed: {_error_message(exc)}", severity="error")
536
+
537
+ async def action_rate_seen(self) -> None:
538
+ """Show modal rating screen; rate and mark the item as seen on confirmation."""
539
+ row_key = self.row_key_highlighted
540
+
541
+ async def check_rating(rating: str | None) -> None:
542
+ """Handle rating screen dismissal; proceed if a rating was selected."""
543
+ _log = log.bind(phase="action_rate_seen", row_key=row_key)
544
+ if rating and row_key:
545
+ await self._do_rate_seen(row_key, rating)
546
+ else:
547
+ _log.debug("Rating cancelled or no row selected")
548
+
549
+ await self.push_screen(RatingsScreen(), check_rating)
550
+
551
+ async def _do_rate_hide(self, row_key: str, rating: str) -> None:
552
+ """Optimistically remove item, then rate+hide in the background."""
553
+ _log = log.bind(phase="action_rate_hide", row_key=row_key, rating=rating)
554
+ source_item = self._item_from_row_key(row_key)
555
+ if not source_item:
556
+ _log.warning("row_key not found in self.info — cannot rate+hide")
557
+ return
558
+ item = dict(source_item)
559
+ item["rating"] = float(rating[1:])
560
+ _log.debug(f"Rating+hide: {item.get('title')!r} score={item.get('rating')}")
561
+ data = json.dumps({self.item_type.api_multi: [item]})
562
+ item_type = self.item_type
563
+ self.api.invalidate_recommendations_cache()
564
+ self.api.invalidate_rated_cache()
565
+ await self._remove_item_optimistically(row_key)
566
+ self._bg_tasks.append(asyncio.create_task(self._rate_hide_bg(data, row_key, item_type, _log)))
567
+
568
+ async def _rate_hide_bg(self, data: str, row_key: str, item_type: MediaType, _log: "Logger") -> None:
569
+ """Send rate and hide requests concurrently; notify on failure."""
570
+ try:
571
+ await asyncio.gather(
572
+ self.api.rate_item(data=data),
573
+ self.api.hide_item(trakt_id=row_key, item_type=item_type),
574
+ )
575
+ _log.debug("Rate+hide complete")
576
+ except httpx.HTTPError as exc:
577
+ _log.error(f"Network error rating+hide: {exc}")
578
+ self.notify(f"Rating+hide failed: {_error_message(exc)}", severity="error")
579
+
580
+ async def action_rate_hide(self) -> None:
581
+ """Show modal rating screen; rate and hide the item on confirmation."""
582
+ row_key = self.row_key_highlighted
583
+
584
+ async def check_rating(rating: str | None) -> None:
585
+ """Handle rating screen dismissal; proceed if a rating was selected."""
586
+ _log = log.bind(phase="action_rate_hide", row_key=row_key)
587
+ if rating and row_key:
588
+ await self._do_rate_hide(row_key, rating)
589
+ else:
590
+ _log.debug("Rating cancelled or no row selected")
591
+
592
+ await self.push_screen(RatingsScreen(), check_rating)
593
+
594
+ def action_open_info(self) -> None:
595
+ """Open the information URL in the default browser."""
596
+ if not self.row_key_highlighted:
597
+ return
598
+
599
+ display_item: TraktItem | None = None
600
+ for item in self.info:
601
+ ids = item.get("ids") or {}
602
+ if str(ids.get("trakt", "")) == self.row_key_highlighted:
603
+ display_item = item
604
+ break
605
+
606
+ if not display_item:
607
+ return
608
+
609
+ ids = display_item.get("ids") or {}
610
+ slug = ids.get("slug")
611
+ if not slug:
612
+ return
613
+
614
+ url = f"{TraktApi.BASE_URL}/{self.item_type.api_multi}/{slug}"
615
+ webbrowser.open(url, autoraise=True)
616
+
617
+ def action_open_in_tmdb(self) -> None:
618
+ """Open the TMDB about this in the default browser."""
619
+ if not self.row_key_highlighted:
620
+ return
621
+
622
+ display_item: TraktItem | None = None
623
+ for item in self.info:
624
+ ids = item.get("ids") or {}
625
+ if str(ids.get("trakt", "")) == self.row_key_highlighted:
626
+ display_item = item
627
+ break
628
+
629
+ if not display_item:
630
+ return
631
+
632
+ ids = display_item.get("ids") or {}
633
+ tmdb_id = ids.get("tmdb")
634
+ if not tmdb_id:
635
+ return
636
+
637
+ url = f"https://www.themoviedb.org/{self.item_type.tmdb}/{tmdb_id}"
638
+ webbrowser.open(url, autoraise=True)
639
+
640
+ def action_exit(self) -> None:
641
+ """Close and exit app."""
642
+ self.app.exit()
643
+
644
+
645
+ def _migrate_credentials_if_needed() -> None:
646
+ """Migrate credentials from .envrc to the credentials file if needed."""
647
+ if not CREDENTIALS_PATH.exists():
648
+ migrated = migrate_from_envrc(Path(".envrc"))
649
+ if migrated:
650
+ write_credentials(migrated)
651
+ log.bind(phase="startup").info("Migrated credentials from .envrc to {}", CREDENTIALS_PATH)
652
+
653
+
654
+ def _resolve_client_credentials(
655
+ configure: bool,
656
+ client_id: str | None,
657
+ client_secret: str | None,
658
+ creds: CredentialsDict,
659
+ ) -> tuple[str, str]:
660
+ """Return (client_id, client_secret) by prompting (configure) or from args/stored creds."""
661
+ if configure:
662
+ resolved_client_id: str = typer.prompt(
663
+ "Trakt Client ID",
664
+ default=client_id or creds.get("client_id"),
665
+ )
666
+ resolved_client_secret: str = typer.prompt(
667
+ "Trakt Client Secret",
668
+ default=client_secret or creds.get("client_secret"),
669
+ hide_input=True,
670
+ )
671
+ return resolved_client_id, resolved_client_secret
672
+
673
+ resolved_client_id = client_id or creds.get("client_id")
674
+ resolved_client_secret = client_secret or creds.get("client_secret")
675
+ if not resolved_client_id or not resolved_client_secret:
676
+ print("Provide TRAKT_CLIENT_ID and TRAKT_CLIENT_SECRET to authenticate.")
677
+ sys.exit(1)
678
+ return resolved_client_id, resolved_client_secret
679
+
680
+
681
+ def _ensure_initial_credentials(
682
+ configure: bool,
683
+ client_id: str | None,
684
+ client_secret: str | None,
685
+ ) -> CredentialsDict:
686
+ """Handle .envrc migration, read credentials, and run OAuth flow when needed."""
687
+ _migrate_credentials_if_needed()
688
+ creds = read_credentials()
689
+
690
+ if configure or not creds.get("access_token"):
691
+ resolved_client_id, resolved_client_secret = _resolve_client_credentials(
692
+ configure, client_id, client_secret, creds
693
+ )
694
+ oauth: OauthDict = {
695
+ "client_id": resolved_client_id,
696
+ "client_secret": resolved_client_secret,
697
+ "oauth_token": None,
698
+ "oauth_refresh": None,
699
+ "refresh_token": None,
700
+ }
701
+ try:
702
+ creds = get_access_token(oauth=oauth)
703
+ write_credentials(creds)
704
+ except httpx.HTTPError as exc:
705
+ print(f"Authentication failed: {_error_message(exc)}", file=sys.stderr)
706
+ sys.exit(1)
707
+
708
+ return creds
709
+
710
+
711
+ def _maybe_renew_token(creds: CredentialsDict) -> CredentialsDict:
712
+ """Renew the access token if it expires within 4 weeks; on failure keep existing creds."""
713
+ token_created_at = creds.get("created_at")
714
+ token_expires_in = creds.get("expires_in")
715
+ if not (token_created_at and token_expires_in):
716
+ return creds
717
+
718
+ now = Instant.now()
719
+ in_4_weeks = now.to_system_tz().add(weeks=4)
720
+ token_expires = Instant.from_timestamp(token_created_at + token_expires_in)
721
+ _log = log.bind(phase="startup")
722
+ _log.debug(f"token_expires={token_expires}")
723
+ if token_expires <= in_4_weeks:
724
+ _log.debug("Renewing auth token, expires in next 4 weeks")
725
+ oauth: OauthDict = {
726
+ "client_id": creds.get("client_id"),
727
+ "client_secret": creds.get("client_secret"),
728
+ "oauth_token": creds.get("access_token"),
729
+ "oauth_refresh": None,
730
+ "refresh_token": creds.get("refresh_token"),
731
+ }
732
+ try:
733
+ creds = renew_access_token(oauth=oauth)
734
+ write_credentials(creds)
735
+ except httpx.HTTPError as exc:
736
+ print(
737
+ f"Token renewal failed ({_error_message(exc)}) — continuing with existing token",
738
+ file=sys.stderr,
739
+ )
740
+ return creds
741
+
742
+
743
+ def tui(
744
+ configure: bool = False,
745
+ client_id: str | None = typer.Option(envvar="TRAKT_CLIENT_ID", default=None),
746
+ client_secret: str | None = typer.Option(envvar="TRAKT_CLIENT_SECRET", default=None),
747
+ limit: int = typer.Option(default=20, envvar="TRAKT_UNRATED_LIMIT"),
748
+ ) -> None:
749
+ """Start the Trakt recommendations TUI."""
750
+ creds = _ensure_initial_credentials(configure, client_id, client_secret)
751
+
752
+ if not creds.get("access_token"):
753
+ print("No credentials found. Run with --configure to authenticate.")
754
+ sys.exit(1)
755
+
756
+ creds = _maybe_renew_token(creds)
757
+
758
+ headers: HeadersDict = {
759
+ "Content-Type": "application/json",
760
+ "Authorization": f"Bearer {creds.get('access_token', '')}",
761
+ "trakt-api-version": "2",
762
+ "trakt-api-key": creds.get("client_id", ""),
763
+ }
764
+
765
+ app = RecommendApp(headers=headers, unrated_limit=limit)
766
+ app.run()
767
+
768
+
769
+ def typer_shim():
770
+ """Run Typer app."""
771
+ typer.run(tui)