├── .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 | --------------------------------------------------------------------------------