├── .gitignore
├── README.md
├── sync_settings.ini.example
└── trakt_letterboxd_sync.py
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | sync_settings.ini
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Tautulli watched sync
2 | Automatically synchronize watched TV Shows to Trakt.tv and movies to Letterboxd
3 |
4 | ## Setup
5 | Download `trakt_letterboxd_sync.py` and `sync_settings.ini.example` to your Tautulli host.
6 | Rename `sync_settings.ini.example` to `sync_settings.ini` and add the `user_ids`, `client_id`, `client_secret`, `api_key` and `api_secret`. See below for more info on these settings.
7 |
8 | **Important!** Make sure `sync-settings.ini` is writable
9 |
10 | ### Settings
11 | `./sync-settings.ini`
12 |
13 | ```
14 | [Plex]
15 | user_ids: a comma separated list of user ids, only entries for these users will be synced
16 | The user id for a user can be found in your url in Tautulli when you click on a user.
17 |
18 | [Trakt]:
19 | Update `client_id` with the `client_id` of your registered application, see here:
20 | https://trakt.tv/oauth/applications > Choose your application
21 |
22 | To set the access code use `urn:ietf:wg:oauth:2.0:oob` as a redirect URI on your application.
23 | Then execute the script:
24 | python ./trakt_letterboxd_sync.py --contentType trakt_authenticate --userId -1
25 | And follow the instructions shown.
26 |
27 | [Letterboxd]
28 | Update `api_key` and `api_secret` with your Letterboxd API Key and API Shared Secret respectively.
29 | Look [here](https://letterboxd.com/api-beta/) as for how to receive these credentials.
30 |
31 | To set the access code execute the script:
32 | python ./trakt_letterboxd_sync.py --contentType letterboxd_authenticate --userId -1
33 | And follow the instructions shown.
34 | ```
35 |
36 | ### Tautulli
37 | ```
38 | Adding the script to Tautulli:
39 | Tautulli > Settings > Notification Agents > Add a new notification agent > Script
40 |
41 | Configuration:
42 | Tautulli > Settings > Notification Agents > New Script > Configuration:
43 |
44 | Script Folder: /path/to/your/scripts
45 | Script File: ./trakt_letterboxd_sync.py (Should be selectable in a dropdown list)
46 | Script Timeout: {timeout}
47 | Description: Trakt.tv and Letterboxd sync
48 | Save
49 |
50 | Triggers:
51 | Tautulli > Settings > Notification Agents > New Script > Triggers:
52 |
53 | Check: Watched
54 | Save
55 |
56 | Conditions:
57 | Tautulli > Settings > Notification Agents > New Script > Conditions:
58 |
59 | Set Conditions: [{condition} | {operator} | {value} ]
60 | Save
61 |
62 | Script Arguments:
63 | Tautulli > Settings > Notification Agents > New Script > Script Arguments:
64 |
65 | Select: Watched
66 | Arguments: --userId {user_id} --contentType {media_type}
67 | --imdbId {imdb_id}
68 | --tvdbId {thetvdb_id} --season {season_num} --episode {episode_num}
69 |
70 | Save
71 | Close
72 | ```
73 |
--------------------------------------------------------------------------------
/sync_settings.ini.example:
--------------------------------------------------------------------------------
1 | [Plex]
2 | user_ids =
3 |
4 | [Trakt]
5 | client_id =
6 | client_secret =
7 | access_token =
8 | refresh_token =
9 |
10 | [Letterboxd]
11 | api_key =
12 | api_secret =
13 | access_token =
14 | refresh_token =
15 |
16 |
--------------------------------------------------------------------------------
/trakt_letterboxd_sync.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 |
4 | """
5 | Description: Sync viewing history with Trakt.tv and Letterboxd
6 | Author: Joost van Someren
7 |
8 | Important!
9 | Make sure `./sync-settings.ini` is writable
10 |
11 | Settings:
12 | ./sync-settings.ini
13 |
14 | [Plex]
15 | user_ids: a comma separated list of user ids, only entries for these users will be synced
16 | The user id for a user can be found in your url in Tautulli when you click on a user.
17 |
18 | [Trakt]:
19 | Update `client_id` with the `client_id` of your registered application, see here:
20 | https://trakt.tv/oauth/applications > Choose your application
21 |
22 | To set the access code use `urn:ietf:wg:oauth:2.0:oob` as a redirect URI on your application.
23 | Then execute the script:
24 | python ./trakt_letterboxd_sync.py --contentType trakt_authenticate --userId -1
25 | And follow the instructions shown.
26 |
27 | [Letterboxd]
28 | Update `api_key` and `api_secret` with your Letterboxd API Key and API Shared Secret respectively.
29 | Look [here](https://letterboxd.com/api-beta/) as for how to receive these credentials.
30 |
31 | To set the access code execute the script:
32 | python ./trakt_letterboxd_sync.py --contentType letterboxd_authenticate --userId -1
33 | And follow the instructions shown.
34 |
35 | Adding the script to Tautulli:
36 | Tautulli > Settings > Notification Agents > Add a new notification agent > Script
37 |
38 | Configuration:
39 | Tautulli > Settings > Notification Agents > New Script > Configuration:
40 |
41 | Script Folder: /path/to/your/scripts
42 | Script File: ./trakt_letterboxd_sync.py (Should be selectable in a dropdown list)
43 | Script Timeout: {timeout}
44 | Description: Trakt.tv and Letterboxd sync
45 | Save
46 |
47 | Triggers:
48 | Tautulli > Settings > Notification Agents > New Script > Triggers:
49 |
50 | Check: Watched
51 | Save
52 |
53 | Conditions:
54 | Tautulli > Settings > Notification Agents > New Script > Conditions:
55 |
56 | Set Conditions: [{condition} | {operator} | {value} ]
57 | Save
58 |
59 | Script Arguments:
60 | Tautulli > Settings > Notification Agents > New Script > Script Arguments:
61 |
62 | Select: Watched
63 | Arguments: --userId {user_id} --contentType {media_type}
64 | --imdbId {imdb_id}
65 | --tvdbId {thetvdb_id} --season {season_num} --episode {episode_num}
66 |
67 | Save
68 | Close
69 | """
70 |
71 | import os
72 | import sys
73 | import requests
74 | import json
75 | import argparse
76 | import datetime
77 | import time
78 | import uuid
79 | import hmac
80 | from getpass import getpass
81 | from hashlib import sha256
82 |
83 | from configparser import ConfigParser, NoOptionError, NoSectionError
84 |
85 | TAUTULLI_ENCODING = os.getenv('TAUTULLI_ENCODING', 'UTF-8')
86 |
87 | credential_path = os.path.dirname(os.path.realpath(__file__))
88 | credential_file = 'sync_settings.ini'
89 |
90 | config = ConfigParser()
91 | try:
92 | with open('%s/%s' % (credential_path,credential_file)) as f:
93 | config.read_file(f)
94 | except IOError:
95 | print('ERROR: %s/%s not found' % (credential_path,credential_file))
96 | sys.exit(1)
97 |
98 | def arg_decoding(arg):
99 | """Decode args, encode UTF-8"""
100 | return arg.decode(TAUTULLI_ENCODING).encode('UTF-8')
101 |
102 | def write_settings():
103 | """Write config back to settings file"""
104 | try:
105 | with open('%s/%s' % (credential_path,credential_file), 'w') as f:
106 | config.write(f)
107 | except IOError:
108 | print('ERROR: unable to write to %s/%s' % (credential_path,credential_file))
109 | sys.exit(1)
110 |
111 | def sync_for_user(user_id):
112 | """Returns wheter or not to sync for the passed user_id"""
113 | try:
114 | user_ids = config.get('Plex', 'user_ids')
115 | except (NoSectionError, NoOptionError):
116 | print('ERROR: %s not setup - missing user_ids' % credential_file)
117 | sys.exit(1)
118 |
119 | return str(user_id) in user_ids.split(',')
120 |
121 | class Trakt:
122 | def __init__(self, tvdb_id, season_num, episode_num):
123 | self.tvdb_id = tvdb_id
124 | self.season_num = season_num
125 | self.episode_num = episode_num
126 |
127 | try:
128 | self.client_id = config.get('Trakt', 'client_id')
129 | except (NoSectionError, NoOptionError):
130 | print('ERROR: %s not setup - missing client_id' % credential_file)
131 | sys.exit(1)
132 |
133 | try:
134 | self.client_secret = config.get('Trakt', 'client_secret')
135 | except (NoSectionError, NoOptionError):
136 | print('ERROR: %s not setup - missing client_secret' % credential_file)
137 | sys.exit(1)
138 |
139 | def get_access_token(self):
140 | try:
141 | return config.get('Trakt', 'access_token')
142 | except (NoSectionError, NoOptionError):
143 | print('ERROR: %s not setup - missing access_token' % credential_file)
144 | sys.exit(1)
145 |
146 | def get_refresh_token(self):
147 | try:
148 | return config.get('Trakt', 'refresh_token')
149 | except (NoSectionError, NoOptionError):
150 | print('ERROR: %s not setup - missing refresh_token' % credential_file)
151 | sys.exit(1)
152 |
153 | def authenticate(self):
154 | headers = {
155 | 'Content-Type': 'application/json'
156 | }
157 |
158 | device_code = self.generate_device_code(headers)
159 | self.poll_access_token(headers, device_code)
160 |
161 | def generate_device_code(self, headers):
162 | payload = {
163 | 'client_id': self.client_id
164 | }
165 |
166 | r = requests.post('https://api.trakt.tv/oauth/device/code', json=payload, headers=headers)
167 | response = r.json()
168 | print('Please go to %s and insert the following code: "%s"' % (response['verification_url'], response['user_code']))
169 |
170 | i = input('I have authorized the application! Press ENTER to continue:')
171 |
172 | return response['device_code']
173 |
174 | def poll_access_token(self, headers, device_code):
175 | payload = {
176 | 'code': device_code,
177 | 'client_id': self.client_id,
178 | 'client_secret': self.client_secret
179 | }
180 |
181 | r = requests.post('https://api.trakt.tv/oauth/device/token', json=payload, headers=headers)
182 | if r.status_code == 400:
183 | i = input('The device hasn\'t been authorized yet, please do so. Press ENTER to continue:')
184 | return self.poll_access_token(self, headers, device_code)
185 | elif r.status_code != 200:
186 | print('Something went wrong, please try again.')
187 | sys.exit(1)
188 |
189 | response = r.json()
190 | config.set('Trakt', 'access_token', response['access_token'])
191 | config.set('Trakt', 'refresh_token', response['refresh_token'])
192 | write_settings()
193 |
194 | print('Succesfully configured your Trakt.tv sync!')
195 |
196 | def refresh_access_token(self):
197 | headers = {
198 | 'Content-Type': 'application/json'
199 | }
200 |
201 | payload = {
202 | 'refresh_token': self.get_refresh_token(),
203 | 'client_id': self.client_id,
204 | 'client_secret': self.client_secret,
205 | 'redirect_uri': 'urn:ietf:wg:oauth:2.0:oob',
206 | 'grant_type': 'refresh_token'
207 | }
208 |
209 | r = requests.post('https://api.trakt.tv/oauth/token', json=payload, headers=headers)
210 | response = r.json()
211 | config.set('Trakt', 'access_token', response['access_token'])
212 | config.set('Trakt', 'refresh_token', response['refresh_token'])
213 | write_settings()
214 |
215 | print('Refreshed access token succesfully!')
216 |
217 | def get_show(self):
218 | headers = {
219 | 'Content-Type': 'application/json',
220 | 'trakt-api-version': '2',
221 | 'trakt-api-key': self.client_id
222 | }
223 |
224 | r = requests.get('https://api.trakt.tv/search/tvdb/' + str(self.tvdb_id) + '?type=show', headers=headers)
225 |
226 | response = r.json()
227 | return response[0]['show']
228 |
229 | def get_episode(self, show):
230 | headers = {
231 | 'Content-Type': 'application/json',
232 | 'trakt-api-version': '2',
233 | 'trakt-api-key': self.client_id
234 | }
235 |
236 | r = requests.get('https://api.trakt.tv/shows/' + str(show['ids']['slug']) + '/seasons/' + str(self.season_num) + '/episodes/' + str(self.episode_num), headers=headers)
237 | response = r.json()
238 | return response
239 |
240 | def sync_history(self):
241 | access_token = self.get_access_token()
242 | watched_at = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z')
243 | show = self.get_show()
244 | episode = self.get_episode(show)
245 |
246 | headers = {
247 | 'Content-Type': 'application/json',
248 | 'Authorization': 'Bearer ' + access_token,
249 | 'trakt-api-version': '2',
250 | 'trakt-api-key': self.client_id
251 | }
252 |
253 | payload = {
254 | 'episodes': [
255 | {
256 | 'watched_at': watched_at,
257 | 'ids': {
258 | 'trakt': episode['ids']['trakt'],
259 | 'tvdb': episode['ids']['tvdb'],
260 | 'imdb': episode['ids']['imdb'],
261 | 'tmdb': episode['ids']['tmdb']
262 | }
263 | }
264 | ]
265 | }
266 |
267 | r = requests.post('https://api.trakt.tv/sync/history', json=payload, headers=headers)
268 |
269 | class Letterboxd:
270 | def __init__(self, imdb_id):
271 | self.base_url = 'https://api.letterboxd.com/api/v0'
272 | self.imdb_id = imdb_id
273 |
274 | self.session = requests.Session()
275 | self.session.params = {}
276 |
277 | try:
278 | self.api_key = config.get('Letterboxd', 'api_key')
279 | except (NoSectionError, NoOptionError):
280 | print('ERROR: %s not setup - missing api_key' % credential_file)
281 | sys.exit(1)
282 |
283 | try:
284 | self.api_secret = config.get('Letterboxd', 'api_secret')
285 | except (NoSectionError, NoOptionError):
286 | print('ERROR: %s not setup - missing api_secret' % credential_file)
287 | sys.exit(1)
288 |
289 | def get_access_token(self):
290 | try:
291 | return config.get('Letterboxd', 'access_token')
292 | except (NoSectionError, NoOptionError):
293 | print('ERROR: %s not setup - missing access_token' % credential_file)
294 | sys.exit(1)
295 |
296 | def get_refresh_token(self):
297 | try:
298 | return config.get('Letterboxd', 'refresh_token')
299 | except (NoSectionError, NoOptionError):
300 | print('ERROR: %s not setup - missing refresh_token' % credential_file)
301 | sys.exit(1)
302 |
303 | def get_request_params(self):
304 | return {
305 | 'apikey': self.api_key,
306 | 'nonce': uuid.uuid4(),
307 | 'timestamp': int(time.time())
308 | }
309 |
310 | def prepare_request(self, method, url, data, params, headers):
311 | request = requests.Request(method.upper(), url, data=data, params=params, headers=headers)
312 |
313 | return self.session.prepare_request(request)
314 |
315 | def get_signature(self, prepared_request):
316 | if prepared_request.body == None:
317 | body = ''
318 | else:
319 | body = prepared_request.body
320 |
321 | signing_bytestring = b"\x00".join(
322 | [str.encode(prepared_request.method), str.encode(prepared_request.url), str.encode(body)]
323 | )
324 |
325 | signature = hmac.new(str.encode(self.api_secret), signing_bytestring, digestmod=sha256)
326 | return signature.hexdigest()
327 |
328 | def authenticate(self):
329 | method = 'post'
330 | url = self.base_url + '/auth/token'
331 |
332 | headers = {
333 | 'Content-Type': 'application/x-www-form-urlencoded',
334 | 'Accept': 'application/json'
335 | }
336 |
337 | username = input('Username or email address: ')
338 | password = getpass('Password: ')
339 |
340 | payload = {
341 | 'grant_type': 'password',
342 | 'username': username,
343 | 'password': password
344 | }
345 |
346 | params = self.get_request_params()
347 |
348 | request = self.prepare_request(method, url, payload, params, headers)
349 | signature = self.get_signature(request)
350 | request.headers['Authorization'] = 'Signature ' + signature
351 |
352 | r = self.session.send(request)
353 | if r.status_code == 400:
354 | print('Something went wrong, you have probably used invalid credentials')
355 | return
356 |
357 | response = r.json()
358 |
359 | config.set('Letterboxd', 'access_token', response['access_token'])
360 | config.set('Letterboxd', 'refresh_token', response['refresh_token'])
361 | write_settings()
362 |
363 | print('Succesfully configured your Letterboxd sync!')
364 |
365 | def refresh_access_token(self):
366 | method = 'post'
367 | url = self.base_url + '/auth/token'
368 |
369 | headers = {
370 | 'Content-Type': 'application/x-www-form-urlencoded',
371 | 'Accept': 'application/json'
372 | }
373 |
374 | payload = {
375 | 'grant_type': 'refresh_token',
376 | 'refresh_token': self.get_refresh_token()
377 | }
378 |
379 | params = self.get_request_params()
380 |
381 | request = self.prepare_request(method, url, payload, params, headers)
382 | signature = self.get_signature(request)
383 | request.headers['Authorization'] = 'Signature ' + signature
384 |
385 | r = self.session.send(request)
386 | if r.status_code == 400:
387 | print('Something went wrong, please authorize using `python ./trakt_letterboxd_sync.py --contentType letterboxd_authenticate --userId -1`')
388 | return
389 |
390 | response = r.json()
391 |
392 | config.set('Letterboxd', 'access_token', response['access_token'])
393 | config.set('Letterboxd', 'refresh_token', response['refresh_token'])
394 | write_settings()
395 |
396 | print('Refreshed access token succesfully!')
397 |
398 | def get_film_id(self):
399 | method = 'get'
400 | url = self.base_url + '/films'
401 |
402 | headers = {
403 | 'Content-Type': 'application/json',
404 | 'Accept': 'application/json'
405 | }
406 |
407 | payload = None
408 |
409 | params = self.get_request_params()
410 | params['filmId'] = 'imdb:' + self.imdb_id
411 |
412 | request = self.prepare_request(method, url, payload, params, headers)
413 | signature = self.get_signature(request)
414 | request.prepare_url(request.url, {'signature': signature})
415 |
416 | r = self.session.send(request)
417 |
418 | response = r.json()
419 | return response['items'][0]['id']
420 |
421 | def log_entry(self):
422 | method = 'post'
423 | url = self.base_url + '/log-entries'
424 |
425 | headers = {
426 | 'Content-Type': 'application/json',
427 | 'Accept': 'application/json'
428 | }
429 |
430 | payload = {
431 | 'filmId': self.get_film_id(),
432 | 'diaryDetails': {
433 | 'diaryDate': datetime.datetime.today().strftime('%Y-%m-%d')
434 | },
435 | 'tags': [
436 | 'plex'
437 | ]
438 | }
439 | payload = json.dumps(payload)
440 |
441 | params = self.get_request_params()
442 |
443 | request = self.prepare_request(method, url, payload, params, headers)
444 | signature = self.get_signature(request)
445 | request.prepare_url(request.url, {'signature': signature})
446 | request.headers['Authorization'] = 'Bearer ' + self.get_access_token()
447 |
448 | r = self.session.send(request)
449 |
450 | response = r.json()
451 | print('Successfully logged diary entry.')
452 |
453 | if __name__ == "__main__":
454 | parser = argparse.ArgumentParser(
455 | description="Syncing viewing activity to Trakt.tv and Letterboxd.")
456 |
457 | parser.add_argument('--userId', required=True, type=int,
458 | help='The user_id of the current user.')
459 |
460 | parser.add_argument('--contentType', required=True, type=str,
461 | help='The type of content, movie or episode.')
462 |
463 | parser.add_argument('--tvdbId', type=int,
464 | help='TVDB ID.')
465 |
466 | parser.add_argument('--season', type=int,
467 | help='Season number.')
468 |
469 | parser.add_argument('--episode', type=int,
470 | help='Episode number.')
471 |
472 | parser.add_argument('--imdbId', type=str,
473 | help='IMDB ID.')
474 |
475 | opts = parser.parse_args()
476 |
477 | if not sync_for_user(opts.userId) and not opts.userId == -1:
478 | print('We will not sync for this user')
479 | sys.exit(0)
480 |
481 | if opts.contentType == 'trakt_authenticate':
482 | trakt = Trakt(None, None, None)
483 | trakt.authenticate()
484 | elif opts.contentType == 'trakt_refresh':
485 | trakt = Trakt(None, None, None)
486 | trakt.refresh_access_token()
487 | elif opts.contentType == 'letterboxd_authenticate':
488 | letterboxd = Letterboxd(None)
489 | letterboxd.authenticate()
490 | elif opts.contentType == 'letterboxd_refresh':
491 | letterboxd = Letterboxd(None)
492 | letterboxd.refresh_access_token()
493 | elif opts.contentType == 'movie':
494 | letterboxd = Letterboxd(opts.imdbId)
495 | letterboxd.refresh_access_token()
496 | letterboxd.log_entry()
497 | elif opts.contentType == 'episode':
498 | trakt = Trakt(opts.tvdbId, opts.season, opts.episode)
499 | trakt.refresh_access_token()
500 | trakt.sync_history()
501 | else:
502 | print('ERROR: %s not found - invalid contentType' % opts.contentType)
503 |
--------------------------------------------------------------------------------