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.
kokopelli-0.6.0/PKG-INFO
ADDED
|
@@ -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)
|