pakt 0.2.1__py3-none-any.whl
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.
- pakt/__init__.py +3 -0
- pakt/__main__.py +6 -0
- pakt/assets/icon.png +0 -0
- pakt/assets/icon.svg +10 -0
- pakt/assets/logo.png +0 -0
- pakt/cli.py +814 -0
- pakt/config.py +222 -0
- pakt/models.py +109 -0
- pakt/plex.py +758 -0
- pakt/scheduler.py +153 -0
- pakt/sync.py +1490 -0
- pakt/trakt.py +575 -0
- pakt/tray.py +137 -0
- pakt/web/__init__.py +5 -0
- pakt/web/app.py +991 -0
- pakt/web/templates/index.html +2327 -0
- pakt-0.2.1.dist-info/METADATA +207 -0
- pakt-0.2.1.dist-info/RECORD +20 -0
- pakt-0.2.1.dist-info/WHEEL +4 -0
- pakt-0.2.1.dist-info/entry_points.txt +2 -0
pakt/config.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Configuration management for Pakt."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_config_dir() -> Path:
|
|
17
|
+
"""Get the configuration directory (platform-appropriate)."""
|
|
18
|
+
if os.name == "nt": # Windows
|
|
19
|
+
base = Path(os.environ.get("APPDATA", Path.home()))
|
|
20
|
+
config_dir = base / "pakt"
|
|
21
|
+
else: # Linux/macOS
|
|
22
|
+
config_dir = Path.home() / ".config" / "pakt"
|
|
23
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
return config_dir
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_cache_dir() -> Path:
|
|
28
|
+
"""Get the cache directory (platform-appropriate)."""
|
|
29
|
+
if os.name == "nt": # Windows
|
|
30
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home()))
|
|
31
|
+
cache_dir = base / "pakt" / "cache"
|
|
32
|
+
else: # Linux/macOS
|
|
33
|
+
cache_dir = Path.home() / ".cache" / "pakt"
|
|
34
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
return cache_dir
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TraktConfig(BaseModel):
|
|
39
|
+
"""Trakt API configuration."""
|
|
40
|
+
|
|
41
|
+
client_id: str = "bc25ba5024e871104e3a090b98e44895670d66d89d5296fa6ed027d6e2a44f9d"
|
|
42
|
+
client_secret: str = "0a3cb10a368e848ef67ebcbdb0f64b437ee5abf17e07e4f216c9aa7346f587f5"
|
|
43
|
+
access_token: str = ""
|
|
44
|
+
refresh_token: str = ""
|
|
45
|
+
expires_at: int = 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SyncConfig(BaseModel):
|
|
49
|
+
"""Sync behavior configuration."""
|
|
50
|
+
|
|
51
|
+
watched_plex_to_trakt: bool = True
|
|
52
|
+
watched_trakt_to_plex: bool = True
|
|
53
|
+
ratings_plex_to_trakt: bool = True
|
|
54
|
+
ratings_trakt_to_plex: bool = True
|
|
55
|
+
collection_plex_to_trakt: bool = False
|
|
56
|
+
watchlist_plex_to_trakt: bool = False
|
|
57
|
+
watchlist_trakt_to_plex: bool = False
|
|
58
|
+
rating_priority: Literal["plex", "trakt", "newest"] = "newest"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SchedulerConfig(BaseModel):
|
|
62
|
+
"""Scheduler configuration."""
|
|
63
|
+
|
|
64
|
+
enabled: bool = False
|
|
65
|
+
interval_hours: int = 0
|
|
66
|
+
run_on_startup: bool = False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ServerSyncOverrides(BaseModel):
|
|
70
|
+
"""Per-server sync option overrides. None = use global setting."""
|
|
71
|
+
|
|
72
|
+
watched_plex_to_trakt: bool | None = None
|
|
73
|
+
watched_trakt_to_plex: bool | None = None
|
|
74
|
+
ratings_plex_to_trakt: bool | None = None
|
|
75
|
+
ratings_trakt_to_plex: bool | None = None
|
|
76
|
+
collection_plex_to_trakt: bool | None = None
|
|
77
|
+
watchlist_plex_to_trakt: bool | None = None
|
|
78
|
+
watchlist_trakt_to_plex: bool | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ServerConfig(BaseModel):
|
|
82
|
+
"""Configuration for a single Plex server."""
|
|
83
|
+
|
|
84
|
+
name: str
|
|
85
|
+
url: str = ""
|
|
86
|
+
token: str = ""
|
|
87
|
+
server_name: str = ""
|
|
88
|
+
enabled: bool = True
|
|
89
|
+
movie_libraries: list[str] = Field(default_factory=list)
|
|
90
|
+
show_libraries: list[str] = Field(default_factory=list)
|
|
91
|
+
excluded_libraries: list[str] = Field(default_factory=list)
|
|
92
|
+
sync: ServerSyncOverrides | None = None
|
|
93
|
+
|
|
94
|
+
def get_sync_option(self, option: str, global_config: SyncConfig) -> bool:
|
|
95
|
+
"""Get effective sync option (server override or global fallback)."""
|
|
96
|
+
if self.sync:
|
|
97
|
+
override = getattr(self.sync, option, None)
|
|
98
|
+
if override is not None:
|
|
99
|
+
return override
|
|
100
|
+
return getattr(global_config, option)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Config(BaseModel):
|
|
104
|
+
"""Unified configuration stored in config.json."""
|
|
105
|
+
|
|
106
|
+
trakt: TraktConfig = Field(default_factory=TraktConfig)
|
|
107
|
+
sync: SyncConfig = Field(default_factory=SyncConfig)
|
|
108
|
+
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
|
|
109
|
+
plex_token: str = ""
|
|
110
|
+
servers: list[ServerConfig] = Field(default_factory=list)
|
|
111
|
+
|
|
112
|
+
def get_server(self, name: str) -> ServerConfig | None:
|
|
113
|
+
"""Get server by name."""
|
|
114
|
+
for server in self.servers:
|
|
115
|
+
if server.name == name:
|
|
116
|
+
return server
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
def get_enabled_servers(self) -> list[ServerConfig]:
|
|
120
|
+
"""Get all enabled servers."""
|
|
121
|
+
return [s for s in self.servers if s.enabled]
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def load(cls, config_dir: Path | None = None) -> Config:
|
|
125
|
+
"""Load configuration from config.json, with migration from legacy formats."""
|
|
126
|
+
if config_dir is None:
|
|
127
|
+
config_dir = get_config_dir()
|
|
128
|
+
|
|
129
|
+
config_file = config_dir / "config.json"
|
|
130
|
+
|
|
131
|
+
if config_file.exists():
|
|
132
|
+
try:
|
|
133
|
+
data = json.loads(config_file.read_text(encoding="utf-8"))
|
|
134
|
+
return cls(**data)
|
|
135
|
+
except (json.JSONDecodeError, Exception) as e:
|
|
136
|
+
logger.warning(f"Failed to load config.json: {e}")
|
|
137
|
+
return cls()
|
|
138
|
+
|
|
139
|
+
# Migration: check for legacy .env and servers.json
|
|
140
|
+
config = _migrate_legacy_config(config_dir)
|
|
141
|
+
if config:
|
|
142
|
+
config.save(config_dir)
|
|
143
|
+
return config
|
|
144
|
+
|
|
145
|
+
return cls()
|
|
146
|
+
|
|
147
|
+
def save(self, config_dir: Path | None = None) -> None:
|
|
148
|
+
"""Save configuration to config.json."""
|
|
149
|
+
if config_dir is None:
|
|
150
|
+
config_dir = get_config_dir()
|
|
151
|
+
|
|
152
|
+
config_file = config_dir / "config.json"
|
|
153
|
+
config_file.write_text(
|
|
154
|
+
self.model_dump_json(indent=2),
|
|
155
|
+
encoding="utf-8",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _migrate_legacy_config(config_dir: Path) -> Config | None:
|
|
160
|
+
"""Migrate from legacy .env to config.json."""
|
|
161
|
+
env_file = config_dir / ".env"
|
|
162
|
+
|
|
163
|
+
if not env_file.exists():
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
logger.info("Migrating .env to config.json")
|
|
167
|
+
|
|
168
|
+
config = Config()
|
|
169
|
+
env_vars = {}
|
|
170
|
+
for line in env_file.read_text().splitlines():
|
|
171
|
+
line = line.strip()
|
|
172
|
+
if line and "=" in line and not line.startswith("#"):
|
|
173
|
+
key, value = line.split("=", 1)
|
|
174
|
+
env_vars[key] = value
|
|
175
|
+
|
|
176
|
+
# Trakt config
|
|
177
|
+
config.trakt.client_id = env_vars.get("TRAKT_CLIENT_ID", config.trakt.client_id)
|
|
178
|
+
config.trakt.client_secret = env_vars.get("TRAKT_CLIENT_SECRET", config.trakt.client_secret)
|
|
179
|
+
config.trakt.access_token = env_vars.get("TRAKT_ACCESS_TOKEN", "")
|
|
180
|
+
config.trakt.refresh_token = env_vars.get("TRAKT_REFRESH_TOKEN", "")
|
|
181
|
+
config.trakt.expires_at = int(env_vars.get("TRAKT_EXPIRES_AT", "0") or "0")
|
|
182
|
+
|
|
183
|
+
# Sync config
|
|
184
|
+
def parse_bool(val: str) -> bool:
|
|
185
|
+
return val.lower() in ("true", "1", "yes")
|
|
186
|
+
|
|
187
|
+
config.sync.watched_plex_to_trakt = parse_bool(env_vars.get("PAKT_SYNC_WATCHED_PLEX_TO_TRAKT", "true"))
|
|
188
|
+
config.sync.watched_trakt_to_plex = parse_bool(env_vars.get("PAKT_SYNC_WATCHED_TRAKT_TO_PLEX", "true"))
|
|
189
|
+
config.sync.ratings_plex_to_trakt = parse_bool(env_vars.get("PAKT_SYNC_RATINGS_PLEX_TO_TRAKT", "true"))
|
|
190
|
+
config.sync.ratings_trakt_to_plex = parse_bool(env_vars.get("PAKT_SYNC_RATINGS_TRAKT_TO_PLEX", "true"))
|
|
191
|
+
config.sync.collection_plex_to_trakt = parse_bool(env_vars.get("PAKT_SYNC_COLLECTION_PLEX_TO_TRAKT", "false"))
|
|
192
|
+
config.sync.watchlist_plex_to_trakt = parse_bool(env_vars.get("PAKT_SYNC_WATCHLIST_PLEX_TO_TRAKT", "false"))
|
|
193
|
+
config.sync.watchlist_trakt_to_plex = parse_bool(env_vars.get("PAKT_SYNC_WATCHLIST_TRAKT_TO_PLEX", "false"))
|
|
194
|
+
|
|
195
|
+
# Scheduler config
|
|
196
|
+
config.scheduler.enabled = parse_bool(env_vars.get("PAKT_SCHEDULER_ENABLED", "false"))
|
|
197
|
+
config.scheduler.interval_hours = int(env_vars.get("PAKT_SCHEDULER_INTERVAL_HOURS", "0") or "0")
|
|
198
|
+
config.scheduler.run_on_startup = parse_bool(env_vars.get("PAKT_SCHEDULER_RUN_ON_STARTUP", "false"))
|
|
199
|
+
|
|
200
|
+
# Legacy Plex config - create a server if URL and token exist
|
|
201
|
+
legacy_plex_url = env_vars.get("PLEX_URL", "")
|
|
202
|
+
legacy_plex_token = env_vars.get("PLEX_TOKEN", "")
|
|
203
|
+
legacy_server_name = env_vars.get("PLEX_SERVER_NAME", "")
|
|
204
|
+
|
|
205
|
+
if legacy_plex_url and legacy_plex_token:
|
|
206
|
+
config.plex_token = legacy_plex_token
|
|
207
|
+
config.servers.append(
|
|
208
|
+
ServerConfig(
|
|
209
|
+
name="default",
|
|
210
|
+
url=legacy_plex_url,
|
|
211
|
+
token=legacy_plex_token,
|
|
212
|
+
server_name=legacy_server_name,
|
|
213
|
+
enabled=True,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Rename .env to .env.bak
|
|
218
|
+
env_file.rename(config_dir / ".env.bak")
|
|
219
|
+
logger.info("Renamed .env to .env.bak")
|
|
220
|
+
|
|
221
|
+
logger.info("Migration complete: created config.json")
|
|
222
|
+
return config
|
pakt/models.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Data models for Pakt."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MediaType(str, Enum):
|
|
13
|
+
MOVIE = "movie"
|
|
14
|
+
SHOW = "show"
|
|
15
|
+
EPISODE = "episode"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TraktIds(BaseModel):
|
|
19
|
+
"""Trakt ID container."""
|
|
20
|
+
|
|
21
|
+
trakt: int | None = None
|
|
22
|
+
slug: str | None = None
|
|
23
|
+
imdb: str | None = None
|
|
24
|
+
tmdb: int | None = None
|
|
25
|
+
tvdb: int | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PlexIds(BaseModel):
|
|
29
|
+
"""Plex ID container."""
|
|
30
|
+
|
|
31
|
+
plex: str # rating key
|
|
32
|
+
guid: str | None = None
|
|
33
|
+
imdb: str | None = None
|
|
34
|
+
tmdb: int | None = None
|
|
35
|
+
tvdb: int | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MediaItem(BaseModel):
|
|
39
|
+
"""Unified media item representation."""
|
|
40
|
+
|
|
41
|
+
title: str
|
|
42
|
+
year: int | None = None
|
|
43
|
+
media_type: MediaType
|
|
44
|
+
trakt_ids: TraktIds | None = None
|
|
45
|
+
plex_ids: PlexIds | None = None
|
|
46
|
+
|
|
47
|
+
# Watch status
|
|
48
|
+
watched: bool = False
|
|
49
|
+
watched_at: datetime | None = None
|
|
50
|
+
plays: int = 0
|
|
51
|
+
|
|
52
|
+
# Rating
|
|
53
|
+
rating: int | None = None # 1-10
|
|
54
|
+
rated_at: datetime | None = None
|
|
55
|
+
|
|
56
|
+
# Episode-specific
|
|
57
|
+
show_title: str | None = None
|
|
58
|
+
season: int | None = None
|
|
59
|
+
episode: int | None = None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def trakt_id(self) -> int | None:
|
|
63
|
+
return self.trakt_ids.trakt if self.trakt_ids else None
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def plex_key(self) -> str | None:
|
|
67
|
+
return self.plex_ids.plex if self.plex_ids else None
|
|
68
|
+
|
|
69
|
+
def __hash__(self) -> int:
|
|
70
|
+
if self.trakt_ids and self.trakt_ids.trakt:
|
|
71
|
+
return hash(("trakt", self.trakt_ids.trakt))
|
|
72
|
+
if self.plex_ids:
|
|
73
|
+
return hash(("plex", self.plex_ids.plex))
|
|
74
|
+
return hash((self.title, self.year))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SyncResult(BaseModel):
|
|
78
|
+
"""Result of a sync operation."""
|
|
79
|
+
|
|
80
|
+
added_to_trakt: int = 0
|
|
81
|
+
added_to_plex: int = 0
|
|
82
|
+
ratings_synced: int = 0
|
|
83
|
+
collection_added: int = 0
|
|
84
|
+
watchlist_added_trakt: int = 0
|
|
85
|
+
watchlist_added_plex: int = 0
|
|
86
|
+
errors: list[str] = Field(default_factory=list)
|
|
87
|
+
skipped: int = 0
|
|
88
|
+
duration_seconds: float = 0.0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class WatchedItem(BaseModel):
|
|
92
|
+
"""Item from Trakt watched endpoint."""
|
|
93
|
+
|
|
94
|
+
plays: int
|
|
95
|
+
last_watched_at: datetime
|
|
96
|
+
last_updated_at: datetime | None = None
|
|
97
|
+
movie: dict[str, Any] | None = None
|
|
98
|
+
show: dict[str, Any] | None = None
|
|
99
|
+
seasons: list[dict[str, Any]] | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class RatedItem(BaseModel):
|
|
103
|
+
"""Item from Trakt ratings endpoint."""
|
|
104
|
+
|
|
105
|
+
rated_at: datetime
|
|
106
|
+
rating: int
|
|
107
|
+
movie: dict[str, Any] | None = None
|
|
108
|
+
show: dict[str, Any] | None = None
|
|
109
|
+
episode: dict[str, Any] | None = None
|