1
|
1 |
|
from plugin.core.constants import GUID_SERVICES |
2
|
1 |
|
from plugin.core.helpers.variable import dict_path |
3
|
1 |
|
from plugin.models import * |
|
|
|
|
4
|
1 |
|
from plugin.preferences import Preferences |
5
|
1 |
|
from plugin.sync.core.enums import SyncActionMode, SyncData |
6
|
|
|
|
7
|
1 |
|
from datetime import datetime, timedelta |
8
|
1 |
|
from trakt import Trakt |
9
|
1 |
|
from trakt_sync.cache.main import Cache |
10
|
1 |
|
import elapsed |
11
|
1 |
|
import logging |
12
|
|
|
|
13
|
1 |
|
log = logging.getLogger(__name__) |
14
|
|
|
|
15
|
|
|
|
16
|
1 |
|
class SyncArtifacts(object): |
17
|
1 |
|
def __init__(self, task): |
18
|
1 |
|
self.task = task |
19
|
|
|
|
20
|
1 |
|
self.artifacts = {} |
21
|
|
|
|
22
|
|
|
# |
23
|
|
|
# Log/Send artifacts |
24
|
|
|
# |
25
|
|
|
|
26
|
1 |
|
def send(self): |
27
|
|
|
action_mode = self.task.configuration['sync.action.mode'] |
28
|
|
|
|
29
|
|
|
if action_mode == SyncActionMode.Update: |
30
|
|
|
self.send_actions() |
31
|
|
|
return True |
32
|
|
|
|
33
|
|
|
if action_mode == SyncActionMode.Log: |
34
|
|
|
self.log_actions() |
35
|
|
|
return True |
36
|
|
|
|
37
|
|
|
raise NotImplementedError('Unable to send artifacts to trakt, action mode %r not supported', action_mode) |
38
|
|
|
|
39
|
1 |
|
@elapsed.clock |
40
|
|
|
def send_actions(self): |
41
|
|
|
changes = False |
42
|
|
|
|
43
|
|
|
for data, action, request in self.flatten(): |
44
|
|
|
changes = True |
45
|
|
|
|
46
|
|
|
# Send artifact to trakt.tv |
47
|
|
|
self.send_action(data, action, **request) |
48
|
|
|
|
49
|
|
|
# Invalidate cache to ensure actions aren't resent |
50
|
|
|
for key, value in request.items(): |
51
|
|
|
if not value: |
52
|
|
|
# Empty media request |
53
|
|
|
continue |
54
|
|
|
|
55
|
|
|
if key == 'shows': |
56
|
|
|
media = Cache.Media.Shows |
57
|
|
|
elif key == 'movies': |
58
|
|
|
media = Cache.Media.Movies |
59
|
|
|
else: |
60
|
|
|
# Unknown media type |
61
|
|
|
continue |
62
|
|
|
|
63
|
|
|
self.task.state.trakt.invalidate( |
64
|
|
|
Cache.Media.get(media), |
65
|
|
|
Cache.Data.get(data) |
66
|
|
|
) |
67
|
|
|
|
68
|
|
|
# Task checkpoint |
69
|
|
|
self.task.checkpoint() |
70
|
|
|
|
71
|
|
|
if not changes: |
72
|
|
|
log.info('trakt.tv profile is up-to-date') |
73
|
|
|
return |
74
|
|
|
|
75
|
|
|
log.info('trakt.tv profile has been updated') |
76
|
|
|
|
77
|
1 |
|
@classmethod |
78
|
|
|
def send_action(cls, data, action, **kwargs): |
79
|
|
|
# Ensure items exist in `kwargs` |
80
|
|
|
if not kwargs: |
81
|
|
|
return False |
82
|
|
|
|
83
|
|
|
if not kwargs.get('movies') and not kwargs.get('shows'): |
84
|
|
|
return False |
85
|
|
|
|
86
|
|
|
# Try retrieve interface for `data` |
87
|
|
|
interface = cls._get_interface(data) |
88
|
|
|
|
89
|
|
|
if interface is None: |
90
|
|
|
log.warn('[%s](%s) Unknown data type', data, action) |
91
|
|
|
return False |
92
|
|
|
|
93
|
|
|
# Try retrieve method for `action` |
94
|
|
|
func = getattr(Trakt[interface], action, None) |
95
|
|
|
|
96
|
|
|
if func is None: |
97
|
|
|
log.warn('[%s](%s) Unable find action in interface', data, action) |
98
|
|
|
return False |
99
|
|
|
|
100
|
|
|
# Send request to trakt.tv |
101
|
|
|
response = func(kwargs) |
102
|
|
|
|
103
|
|
|
if response is None: |
104
|
|
|
return False |
105
|
|
|
|
106
|
|
|
log.debug('[%s](%s) Response: %r', data, action, response) |
107
|
|
|
return True |
108
|
|
|
|
109
|
1 |
|
def log_actions(self): |
110
|
|
|
for data, action, request in self.flatten(): |
111
|
|
|
# Try retrieve interface for `data` |
112
|
|
|
interface = self._get_interface(data) |
113
|
|
|
|
114
|
|
|
if interface is None: |
115
|
|
|
log.warn('[%s](%s) Unknown data type', data, action) |
116
|
|
|
continue |
117
|
|
|
|
118
|
|
|
# Log request items |
119
|
|
|
for media, items in request.items(): |
120
|
|
|
self.log_items(interface, action, media, items) |
121
|
|
|
|
122
|
1 |
|
def log_items(self, interface, action, media, items): |
123
|
|
|
if not items: |
124
|
|
|
return |
125
|
|
|
|
126
|
|
|
# Log each item |
127
|
|
|
for item in items: |
128
|
|
|
if not item: |
129
|
|
|
continue |
130
|
|
|
|
131
|
|
|
log.info('[%s:%s](%s) %r (%r)', interface, action, media, item.get('title'), item.get('year')) |
132
|
|
|
|
133
|
|
|
if media == 'shows': |
134
|
|
|
# Log each episode |
135
|
|
|
self.log_episodes(item) |
136
|
|
|
|
137
|
1 |
|
def log_episodes(self, item): |
|
|
|
|
138
|
|
|
for season in item.get('seasons', []): |
139
|
|
|
episodes = season.get('episodes') |
140
|
|
|
|
141
|
|
|
if episodes is None: |
142
|
|
|
log.info(' S%02d', season.get('number')) |
143
|
|
|
continue |
144
|
|
|
|
145
|
|
|
for episode in episodes: |
146
|
|
|
log.info(' S%02dE%02d', season.get('number'), episode.get('number')) |
147
|
|
|
|
148
|
1 |
|
@staticmethod |
149
|
|
|
def _get_interface(data): |
150
|
|
|
# Try retrieve interface for `data` |
151
|
|
|
interface = Cache.Data.get_interface(data) |
152
|
|
|
|
153
|
|
|
if interface == 'sync/watched': |
154
|
|
|
# Watched add/remove functions are on the "sync/history" interface |
155
|
|
|
return 'sync/history' |
156
|
|
|
|
157
|
|
|
return interface |
158
|
|
|
|
159
|
|
|
# |
160
|
|
|
# Artifact storage |
161
|
|
|
# |
162
|
|
|
|
163
|
1 |
View Code Duplication |
def store_show(self, data, action, guid, p_show=None, **kwargs): |
|
|
|
|
164
|
|
|
key = (guid.service, guid.id) |
165
|
|
|
|
166
|
|
|
shows = dict_path(self.artifacts, [ |
167
|
|
|
data, |
168
|
|
|
action, |
169
|
|
|
'shows' |
170
|
|
|
]) |
171
|
|
|
|
172
|
|
|
# Build show |
173
|
|
|
if key in shows: |
174
|
|
|
show = shows[key] |
175
|
|
|
else: |
176
|
|
|
show = self._build_request(guid, p_show, **kwargs) |
177
|
|
|
|
178
|
|
|
if show is None: |
179
|
|
|
return False |
180
|
|
|
|
181
|
|
|
# Store `show` in artifacts |
182
|
|
|
shows[key] = show |
183
|
|
|
|
184
|
|
|
# Set `kwargs` on `show` |
185
|
|
|
self._set_kwargs(show, kwargs) |
186
|
|
|
return True |
187
|
|
|
|
188
|
1 |
|
def store_episode(self, data, action, guid, identifier, p_key=None, p_show=None, p_episode=None, **kwargs): |
|
|
|
|
189
|
1 |
|
key = (guid.service, guid.id) |
190
|
1 |
|
season_num, episode_num = identifier |
191
|
|
|
|
192
|
1 |
|
shows = dict_path(self.artifacts, [ |
193
|
|
|
data, |
194
|
|
|
action, |
195
|
|
|
'shows' |
196
|
|
|
]) |
197
|
|
|
|
198
|
|
|
# Check for duplicate history addition |
199
|
1 |
|
if self._is_duplicate(data, action, p_key): |
200
|
|
|
return False |
201
|
|
|
|
202
|
|
|
# Build show |
203
|
1 |
|
if key in shows: |
204
|
|
|
show = shows[key] |
205
|
|
|
else: |
206
|
1 |
|
show = self._build_request(guid, p_show) |
207
|
|
|
|
208
|
1 |
|
if show is None: |
209
|
|
|
return False |
210
|
|
|
|
211
|
1 |
|
shows[key] = show |
212
|
|
|
|
213
|
|
|
# Ensure 'seasons' attribute exists |
214
|
1 |
|
if 'seasons' not in show: |
215
|
1 |
|
show['seasons'] = {} |
216
|
|
|
|
217
|
|
|
# Build season |
218
|
1 |
|
if season_num in show['seasons']: |
219
|
|
|
season = show['seasons'][season_num] |
220
|
|
|
else: |
221
|
1 |
|
season = show['seasons'][season_num] = {'number': season_num} |
222
|
1 |
|
season['episodes'] = {} |
223
|
|
|
|
224
|
|
|
# Build episode |
225
|
1 |
|
if episode_num in season['episodes']: |
226
|
|
|
episode = season['episodes'][episode_num] |
227
|
|
|
else: |
228
|
1 |
|
episode = season['episodes'][episode_num] = {'number': episode_num} |
229
|
|
|
|
230
|
|
|
# Set `kwargs` on `episode` |
231
|
1 |
|
self._set_kwargs(episode, kwargs) |
232
|
1 |
|
return True |
233
|
|
|
|
234
|
1 |
View Code Duplication |
def store_movie(self, data, action, guid, p_key=None, p_movie=None, **kwargs): |
|
|
|
|
235
|
1 |
|
key = (guid.service, guid.id) |
236
|
|
|
|
237
|
1 |
|
movies = dict_path(self.artifacts, [ |
238
|
|
|
data, |
239
|
|
|
action, |
240
|
|
|
'movies' |
241
|
|
|
]) |
242
|
|
|
|
243
|
|
|
# Check for duplicate history addition |
244
|
1 |
|
if self._is_duplicate(data, action, p_key): |
245
|
|
|
return False |
246
|
|
|
|
247
|
|
|
# Build movie |
248
|
1 |
|
if key in movies: |
249
|
|
|
movie = movies[key] |
250
|
|
|
else: |
251
|
1 |
|
movie = self._build_request(guid, p_movie, **kwargs) |
252
|
|
|
|
253
|
1 |
|
if movie is None: |
254
|
|
|
return False |
255
|
|
|
|
256
|
|
|
# Store `movie` in artifacts |
257
|
1 |
|
movies[key] = movie |
258
|
|
|
|
259
|
|
|
# Set `kwargs` on `movie` |
260
|
1 |
|
self._set_kwargs(movie, kwargs) |
261
|
1 |
|
return True |
262
|
|
|
|
263
|
1 |
|
@classmethod |
264
|
|
|
def _build_request(cls, guid, p_item, **kwargs): |
265
|
|
|
# Validate request |
266
|
1 |
|
if not cls._validate_request(guid, p_item): |
267
|
|
|
return None |
268
|
|
|
|
269
|
|
|
# Build request |
270
|
1 |
|
request = { |
271
|
|
|
'ids': {} |
272
|
|
|
} |
273
|
|
|
|
274
|
|
|
# Set identifier |
275
|
1 |
|
request['ids'][guid.service] = guid.id |
276
|
|
|
|
277
|
|
|
# Set extra attributes |
278
|
1 |
|
cls._set_kwargs(request, kwargs) |
279
|
|
|
|
280
|
1 |
|
return request |
281
|
|
|
|
282
|
1 |
|
def _is_duplicate(self, data, action, p_key): |
283
|
1 |
|
if data != SyncData.Watched or action != 'add': |
284
|
1 |
|
return False |
285
|
|
|
|
286
|
|
|
# Retrieve scrobble duplication period |
287
|
|
|
duplication_period = Preferences.get('scrobble.duplication_period') |
288
|
|
|
|
289
|
|
|
if duplication_period is None: |
290
|
|
|
return False |
291
|
|
|
|
292
|
|
|
# Check for duplicate scrobbles in `duplication_period` |
293
|
|
|
# TODO check `part` attribute |
|
|
|
|
294
|
|
|
scrobbled = ActionHistory.has_scrobbled( |
295
|
|
|
self.task.account, p_key, |
296
|
|
|
after=datetime.utcnow() - timedelta(minutes=duplication_period) |
297
|
|
|
) |
298
|
|
|
|
299
|
|
|
if scrobbled: |
300
|
|
|
log.info( |
301
|
|
|
'Ignoring duplicate history addition, scrobble already performed in the last %d minutes', |
302
|
|
|
duplication_period |
303
|
|
|
) |
304
|
|
|
return True |
305
|
|
|
|
306
|
|
|
return False |
307
|
|
|
|
308
|
1 |
|
@classmethod |
309
|
|
|
def _validate_request(cls, guid, p_item): |
310
|
|
|
# Build item identifier |
311
|
1 |
|
if p_item: |
312
|
1 |
|
identifier = '<%r (%r)>' % (p_item.get('title'), p_item.get('year')) |
313
|
|
|
else: |
314
|
|
|
identifier = repr(guid) |
315
|
|
|
|
316
|
|
|
# Validate parameters |
317
|
1 |
|
if p_item is not None and (not p_item.get('title') or not p_item.get('year')): |
318
|
|
|
log.info('Invalid "title" or "year" attribute on %s', identifier) |
319
|
|
|
return False |
320
|
|
|
|
321
|
1 |
|
if not guid or not guid.valid: |
322
|
|
|
log.warn('Invalid GUID attribute on %s (guid: %r)', identifier, guid) |
323
|
|
|
return False |
324
|
|
|
|
325
|
1 |
|
if guid.service not in GUID_SERVICES: |
326
|
|
|
log.warn('GUID service %r is not supported on %s', guid.service if guid else None, identifier) |
327
|
|
|
return False |
328
|
|
|
|
329
|
1 |
|
return True |
330
|
|
|
|
331
|
1 |
|
@staticmethod |
332
|
|
|
def _set_kwargs(request, kwargs): |
333
|
1 |
|
for key, value in kwargs.items(): |
334
|
1 |
|
if type(value) is datetime: |
335
|
1 |
|
try: |
336
|
|
|
# Convert `datetime` object to string |
337
|
1 |
|
value = value.strftime('%Y-%m-%dT%H:%M:%S') + '.000-00:00' |
338
|
|
|
except Exception: |
339
|
|
|
log.warn('Unable to convert %r to string', value) |
340
|
|
|
return False |
341
|
|
|
|
342
|
1 |
|
request[key] = value |
343
|
|
|
|
344
|
1 |
|
return True |
345
|
|
|
|
346
|
|
|
# |
347
|
|
|
# Flatten |
348
|
|
|
# |
349
|
|
|
|
350
|
1 |
|
def flatten(self): |
351
|
|
|
for data, actions in self.artifacts.items(): |
352
|
|
|
for action, request in actions.items(): |
353
|
|
|
if 'shows' in request: |
354
|
|
|
request['shows'] = list(self.flatten_shows(request['shows'])) |
355
|
|
|
|
356
|
|
|
if 'movies' in request: |
357
|
|
|
request['movies'] = request['movies'].values() |
358
|
|
|
|
359
|
|
|
yield data, action, request |
360
|
|
|
|
361
|
1 |
|
@staticmethod |
362
|
|
|
def flatten_shows(shows): |
363
|
|
|
for show in shows.itervalues(): |
364
|
|
|
if 'seasons' not in show: |
365
|
|
|
yield show |
366
|
|
|
continue |
367
|
|
|
|
368
|
|
|
show['seasons'] = show['seasons'].values() |
369
|
|
|
|
370
|
|
|
for season in show['seasons']: |
371
|
|
|
if 'episodes' not in season: |
372
|
|
|
continue |
373
|
|
|
|
374
|
|
|
season['episodes'] = season['episodes'].values() |
375
|
|
|
|
376
|
|
|
yield show |
377
|
|
|
|
It is generally discouraged to redefine built-ins as this makes code very hard to read.