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/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