├── .gitignore ├── LICENSE ├── README.md ├── imgs ├── mpv_screenshot.jpg ├── msg_trakt.png └── trakt_screenshot.jpg ├── trakt-mpv.lua └── trakt-mpv ├── config_example.json └── main.py /.gitignore: -------------------------------------------------------------------------------- 1 | trakt-mpv/config.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Luís Pinto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # trakt-mpv 2 | 3 | A MPV script that checks in your movies and shows with Trakt.tv without the need for an IPC server. 4 | 5 | | ![mpv_screenshot](imgs/mpv_screenshot.jpg) | ![mpv_screenshot](imgs/trakt_screenshot.jpg) | 6 | | ------------------------------------------ | -------------------------------------------- | 7 | 8 | ## Archival Notice 9 | 10 | **As you can see this repository has been archived. I've archived it since I've beeen busy lately and haven't really used mpv.net for a while. The code stills works perfectly and will scrobble on trakt.tv, however, I'm unable to dedicate more time to this project 😔.** 11 | 12 | ## How does it work? 13 | 14 | This script is written in both Lua and Python. The Lua part works as a front-end while the Python script is responsible for communicating with Trakt.tv. 15 | 16 | This dual-language approach was needed since mpv scripts aren't able to send http requests and/or edit files natively. 17 | 18 | ## How to install? 19 | 20 | ### Pre-requisites 21 | 22 | In order for this script to work you need to make sure you have Python 3 installed. 23 | 24 | After that, make sure you also have the `requests` module. You can install it like this: 25 | 26 | ``` 27 | pip install requests 28 | ``` 29 | 30 | ### Installing 31 | 32 | The install is pretty simple and can be done with the following steps: 33 | 34 | 1. Move **trakt-mpv.lua** and **trakt-mpv** folder to your scripts folder 35 | - *NOTE: If you are on Windows it will automatically assume that you are using mpv.net with the configuration in APPDATA. If you aren't please change it in the lua script `evoque_python` function.* 36 | 2. Create a trakt.tv api. You can do this using: [https://trakt.tv/oauth/applications](https://trakt.tv/oauth/applications) 37 | 3. Copy your **client_id** and **client_secret** to **trakt-mpv/config_example.json** 38 | 4. Rename **trakt-mpv/config_example.json** to **trakt-mpv/config.json** 39 | 40 | Ok the hard part is done, now you'll do the rest in mpv. If you did everything correctly when you open a file the following message will appear: 41 | 42 | ![Press X to authenticate with Trakt.tv](imgs/msg_trakt.png) 43 | 44 | Press X and follow the instructions on the screen. After that you are all set 😀. 45 | 46 | ## Behaviors 47 | 48 | The current behaviors adopted by the plugin are: 49 | 50 | - It will start a scrobble as soon as the video starts. 51 | - If you starting watching something while trakt.tv is still scrobbing, it will stop the scrobble, count it as seen and start scrobbing the current file (independently of how much you watched the previous one). 52 | - Right now there really isn't a good error reporting. So if you find an error I suggest you look at the mpv console. 53 | 54 | ## Improvements 55 | 56 | Some improvements that can be done are: 57 | 58 | - [ ] Start scrobbing only after x seconds of playback. This would avoid acidental scrobbles. 59 | - [ ] Allow the user to cancel a scrobble. 60 | - [ ] Allow a backup plan for when the show/movie isn't recognized. 61 | - [ ] Test in platforms other than Windows and mpv.net. 62 | 63 | ## Contributing 64 | 65 | Pull requests are very welcome. I don't have a strict CONTRIBUTING guide since this is a small project, so just make sure you are clear on what you worked on 😉. 66 | 67 | ## License 68 | 69 | [MIT](https://choosealicense.com/licenses/mit/) 70 | -------------------------------------------------------------------------------- /imgs/mpv_screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LiTO773/trakt-mpv/7e322262690b9618156a286a336b0dffd84cfd51/imgs/mpv_screenshot.jpg -------------------------------------------------------------------------------- /imgs/msg_trakt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LiTO773/trakt-mpv/7e322262690b9618156a286a336b0dffd84cfd51/imgs/msg_trakt.png -------------------------------------------------------------------------------- /imgs/trakt_screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LiTO773/trakt-mpv/7e322262690b9618156a286a336b0dffd84cfd51/imgs/trakt_screenshot.jpg -------------------------------------------------------------------------------- /trakt-mpv.lua: -------------------------------------------------------------------------------- 1 | -- GLOBAL VARS: 2 | Current_action = "" 3 | 4 | -- HELPER FUNCTIONS: 5 | -- Joins two tables 6 | local function merge_tables(t1, t2) 7 | for k,v in ipairs(t2) do 8 | table.insert(t1, v) 9 | end 10 | 11 | return t1 12 | end 13 | 14 | 15 | -- Calls the Python file 16 | local function evoque_python(flags) 17 | -- Find the path 18 | local location 19 | 20 | if os.getenv("HOME") == nil then 21 | -- If you are using Windows, it will assume you are using mpv.net 22 | location = os.getenv("APPDATA") .. "/mpv.net/Scripts/trakt-mpv/main.py" 23 | else 24 | -- If you are using Linux, it will assume you are using mpv 25 | location = os.getenv("HOME") .. "/.config/mpv/scripts/trakt-mpv/main.py" 26 | end 27 | 28 | -- Add the flags 29 | local args = merge_tables({ "python", location }, flags) 30 | 31 | -- Call the file 32 | local r = mp.command_native({ 33 | name = "subprocess", 34 | capture_stdout = true, 35 | args = args, 36 | }) 37 | 38 | return r.status, r.stdout 39 | end 40 | 41 | -- Sends a message 42 | local function send_message(msg, color, time) 43 | local ass_start = mp.get_property_osd("osd-ass-cc/0") 44 | local ass_stop = mp.get_property_osd("osd-ass-cc/1") 45 | mp.osd_message(ass_start .. "{\\1c&H" .. color .. "&}" .. msg .. ass_stop, time) 46 | end 47 | 48 | -- Activate Function 49 | local function activated() 50 | local status, output = evoque_python({"--auth"}) 51 | 52 | if status == 0 then 53 | send_message("It's done. Enjoy!", "00FF00", 3) 54 | mp.remove_key_binding("auth-trakt") 55 | else 56 | send_message("Damn, there was an error in Python :/ Check the console for more info.", "0000FF", 4) 57 | end 58 | end 59 | 60 | local function activation() 61 | send_message("Querying trakt.tv... Hold tight", "FFFFFF", 10) 62 | local status, output = evoque_python({"--code"}) 63 | 64 | if status == 0 then 65 | send_message("Open https://trakt.tv/activate and type: " .. output .. "\nPress x when done", "FF8800", 50) 66 | mp.remove_key_binding("auth-trakt") 67 | mp.add_forced_key_binding("x", "auth-trakt", activated) 68 | else 69 | send_message("Damn, there was an error in Python :/ Check the console for more info.", "0000FF", 4) 70 | end 71 | end 72 | 73 | -- Checkin Function 74 | local function checkin() 75 | local status, output = evoque_python({"--query", mp.get_property('filename')}) 76 | 77 | if status == 0 then 78 | send_message("Scrobbing " .. output, "00FF00", 2) 79 | elseif status == 14 then 80 | send_message("Couldn't find the show in trakt", "0000FF", 2) 81 | else 82 | send_message("Unable to scrobble " .. output, "0000FF", 2) 83 | end 84 | end 85 | 86 | -- MAIN FUNCTION 87 | 88 | local function on_file_start(event) 89 | local status = evoque_python({"--hello"}) 90 | 91 | -- Check status and act accordingly 92 | if status == 10 then 93 | -- Plugin is yet to be configured 94 | send_message("[trakt-mpv] Please add your client_id and client_secret to config.json!", "0000FF", 4) 95 | return 96 | elseif status == 11 then 97 | -- Plugin has to authenticate 98 | send_message("[trakt-mpv] Press X to authenticate with Trakt.tv", "FF8800", 4) 99 | mp.add_forced_key_binding("x", "auth-trakt", activation) 100 | elseif status == 0 then 101 | -- Plugin is setup, start the checkin 102 | checkin() 103 | end 104 | end 105 | 106 | mp.register_event("file-loaded", on_file_start) -------------------------------------------------------------------------------- /trakt-mpv/config_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "client_id": "TRAKTTV API CLIENT ID", 3 | "client_secret": "TRAKTTV API CLIENT SECRET" 4 | } 5 | -------------------------------------------------------------------------------- /trakt-mpv/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Python script is responsible for executing the web requests. 3 | Each request is dictated by the flag received. 4 | 5 | TODO: Add the ability to refresh token 6 | """ 7 | import re 8 | import sys 9 | import os 10 | import json 11 | from time import sleep 12 | 13 | import requests 14 | from datetime import date 15 | 16 | """ 17 | HELPERS 18 | """ 19 | 20 | 21 | def write_json(data): 22 | with open(os.path.dirname(os.path.abspath(__file__)) + '/config.json', 'w') as outfile: 23 | json.dump(data, outfile, indent=4) 24 | 25 | 26 | def clean_name(name): 27 | """ Removes special characters and the year """ 28 | result = name.replace('.', ' ') 29 | result = result.replace('_', ' ') 30 | result = re.sub(r'\(.*\)|-|\[.*\]', '', result) 31 | result = re.sub(r'([1-9][0-9]{3})', '', result) 32 | 33 | return result 34 | 35 | 36 | """ 37 | REQUESTS 38 | """ 39 | 40 | 41 | def hello(flags, configs): 42 | """ 43 | This function is called as an initial setup. It creates a 15 second delay before responding, so no scrobble happens 44 | by mistake. 45 | - Checks if the client_id and client_secret have already been set (if not, exits as 10) 46 | - Checks if the access_token has already been set (if not, exits as 11) 47 | - Checks if there is a need to refresh the token (automaticly refreshes and exits as 0) 48 | """ 49 | sleep(15) 50 | if 'client_id' not in configs or 'client_secret' not in configs or len(configs['client_id']) != 64 or len(configs['client_secret']) != 64: 51 | sys.exit(10) 52 | 53 | if 'access_token' not in configs or len(configs['access_token']) != 64: 54 | sys.exit(11) 55 | 56 | # TODO Refresh token 57 | sys.exit(0) 58 | 59 | 60 | def code(flags, configs): 61 | """ Generate the code """ 62 | res = requests.post('https://api.trakt.tv/oauth/device/code', json={'client_id': configs['client_id']}) 63 | 64 | configs['device_code'] = res.json()['device_code'] 65 | write_json(configs) 66 | 67 | print(res.json()['user_code'], end='') 68 | 69 | 70 | def auth(flags, configs): 71 | """ Authenticate """ 72 | res = requests.post('https://api.trakt.tv/oauth/device/token', json={ 73 | 'client_id': configs['client_id'], 74 | 'client_secret': configs['client_secret'], 75 | 'code': configs['device_code'] 76 | }) 77 | 78 | res_json = res.json() 79 | 80 | if 'access_token' in res_json: 81 | # Success 82 | configs['access_token'] = res_json['access_token'] 83 | configs['refresh_token'] = res_json['refresh_token'] 84 | del configs['device_code'] 85 | configs['today'] = str(date.today()) 86 | 87 | # Get the user's slug 88 | res = requests.get('https://api.trakt.tv/users/settings', headers={ 89 | 'trakt-api-key': configs['client_id'], 90 | 'Authorization': 'Bearer ' + configs['access_token'], 91 | 'trakt-api-version': '2' 92 | }) 93 | 94 | if res.status_code != 200: 95 | sys.exit(-1) 96 | 97 | configs['user_slug'] = res.json()['user']['ids']['slug'] 98 | write_json(configs) 99 | sys.exit(0) 100 | 101 | sys.exit(-1) 102 | 103 | 104 | def query(flags, configs): 105 | """ Searches Trakt.tv for the content that it's being watched """ 106 | media = flags[2] 107 | 108 | # Check if it is an episode (Show name followed by the season an episode) 109 | infos = re.search(r'(.+)S([0-9]+).*E([0-9]+).*', media, re.IGNORECASE) 110 | 111 | if infos is not None and len(infos.groups()) == 3: 112 | name = infos.group(1) 113 | season_id = infos.group(2) 114 | ep_id = infos.group(3) 115 | __query_search_ep(name, season_id, ep_id, configs) 116 | 117 | # It's not an episode, then it must be a movie (Movie name followed by the year) 118 | infos = re.search(r'(.+)([1-9][0-9]{3}).*', media, re.IGNORECASE) 119 | 120 | if infos is not None and len(infos.groups()) == 2: 121 | movie_year = infos.group(2) 122 | __query_movie(infos.group(1), infos.group(2), configs) 123 | 124 | # Neither of the patterns matched, try using the whole name (Name followed by the file extension) 125 | infos = re.search(r'(.+)\.[0-9A-Za-z]{3}', media, re.IGNORECASE) 126 | __query_whatever(infos.group(1), configs) 127 | 128 | 129 | def __query_search_ep(name, season, ep, configs): 130 | """ Get the episode """ 131 | res = requests.get( 132 | 'https://api.trakt.tv/search/show', 133 | params={'query': clean_name(name)}, 134 | headers={'trakt-api-key': configs['client_id'], 'trakt-api-version': '2'} 135 | ) 136 | 137 | if res.status_code != 200: 138 | sys.exit(-1) 139 | 140 | if len(res.json()) == 0: 141 | sys.exit(14) 142 | 143 | # Found it! 144 | show_title = res.json()[0]['show']['title'] 145 | show_slug = res.json()[0]['show']['ids']['slug'] 146 | show_trakt_id = res.json()[0]['show']['ids']['trakt'] 147 | 148 | print(show_title + ' S' + season + 'E' + ep, end='') 149 | 150 | # Get the episode 151 | res = requests.get( 152 | 'https://api.trakt.tv/shows/' + show_slug + '/seasons/' + season + '/episodes/' + ep, 153 | headers={'trakt-api-key': configs['client_id'], 'trakt-api-version': '2'} 154 | ) 155 | 156 | if res.status_code != 200: 157 | sys.exit(-1) 158 | 159 | checkin(configs, { 160 | 'show': {'ids': {'trakt': show_trakt_id}}, 161 | 'episode': {'season': season, 'number': ep}, 162 | 'app_version': '2.0' 163 | }) 164 | 165 | 166 | def __query_movie(movie, year, configs): 167 | """ Get the movie """ 168 | res = requests.get( 169 | 'https://api.trakt.tv/search/movie', 170 | params={'query': clean_name(movie)}, 171 | headers={'trakt-api-key': configs['client_id'], 'trakt-api-version': '2'} 172 | ) 173 | 174 | if res.status_code != 200: 175 | sys.exit(-1) 176 | 177 | show_title = res.json()[0]['movie']['title'] 178 | show_slug = res.json()[0]['movie']['ids']['slug'] 179 | show_trakt_id = res.json()[0]['movie']['ids']['trakt'] 180 | 181 | if len(res.json()) == 0: 182 | sys.exit(14) 183 | 184 | # Find the movie by year 185 | for obj in res.json(): 186 | if obj['movie']['year'] == int(year): 187 | show_title = obj['movie']['title'] 188 | show_slug = obj['movie']['ids']['slug'] 189 | show_trakt_id = obj['movie']['ids']['trakt'] 190 | 191 | print(show_title, end='') 192 | 193 | checkin(configs, { 194 | 'movie': {'ids': {'trakt': show_trakt_id}}, 195 | 'app_version': '2.0' 196 | }) 197 | 198 | 199 | def __query_whatever(name, configs): 200 | """ Get something purely by the name """ 201 | res = requests.get( 202 | 'https://api.trakt.tv/search/movie', 203 | params={'query': clean_name(name)}, 204 | headers={'trakt-api-key': configs['client_id'], 'trakt-api-version': '2'} 205 | ) 206 | 207 | if res.status_code != 200: 208 | sys.exit(-1) 209 | 210 | if len(res.json()) == 0: 211 | sys.exit(14) 212 | 213 | # Find the first result 214 | show_title = res.json()[0]['movie']['title'] 215 | show_slug = res.json()[0]['movie']['ids']['slug'] 216 | show_trakt_id = res.json()[0]['movie']['ids']['trakt'] 217 | 218 | print(show_title, end='') 219 | 220 | checkin(configs, { 221 | 'movie': {'ids': {'trakt': show_trakt_id}}, 222 | 'app_version': '2.0' 223 | }) 224 | 225 | 226 | def checkin(configs, body): 227 | res = requests.post( 228 | 'https://api.trakt.tv/checkin', 229 | headers={ 230 | 'trakt-api-key': configs['client_id'], 231 | 'trakt-api-version': '2', 232 | 'Authorization': 'Bearer ' + configs['access_token'] 233 | }, 234 | json=body 235 | ) 236 | 237 | if res.status_code == 409: 238 | cancel_previous_scrobble(configs, body) 239 | elif res.status_code != 201: 240 | sys.exit(-1) 241 | sys.exit(0) 242 | 243 | 244 | def cancel_previous_scrobble(configs, body): 245 | """ Cancels the previous scrobble, saves it and starts a new one """ 246 | # Get the current scrobble 247 | res = requests.get( 248 | 'https://api.trakt.tv/users/' + configs['user_slug'] + '/watching', 249 | headers={ 250 | 'trakt-api-key': configs['client_id'], 251 | 'trakt-api-version': '2' 252 | }, 253 | json=body 254 | ) 255 | 256 | if res.status_code == 204: 257 | # Scrobble ended 258 | checkin(configs, body) 259 | return 260 | elif res.status_code != 200: 261 | # Error 262 | sys.exit(-1) 263 | 264 | # Get current scrobble 265 | scrobble_dict = {} 266 | scrobble_dict['progress'] = '100' 267 | scrobble_dict['action'] = 'scrobble' 268 | scrobble_dict['app_version'] = '2.0' 269 | 270 | # Check if it is scrobbing a show or a movie 271 | if 'episode' in res.json().keys(): 272 | scrobble_dict['episode'] = res.json()['episode'] 273 | else: 274 | scrobble_dict['movie'] = res.json()['movie'] 275 | 276 | res = requests.delete( 277 | 'https://api.trakt.tv/checkin', 278 | headers={ 279 | 'trakt-api-key': configs['client_id'], 280 | 'trakt-api-version': '2', 281 | 'Authorization': 'Bearer ' + configs['access_token'] 282 | }, 283 | json=body 284 | ) 285 | 286 | if res.status_code == 204: 287 | # Successful cancel, save the scrobble and start the new one 288 | 289 | requests.post( 290 | 'https://api.trakt.tv/scrobble/stop', 291 | headers={ 292 | 'trakt-api-key': configs['client_id'], 293 | 'trakt-api-version': '2', 294 | 'Authorization': 'Bearer ' + configs['access_token'] 295 | }, 296 | json=scrobble_dict 297 | ) 298 | 299 | checkin(configs, body) 300 | else: 301 | sys.exit(-1) 302 | 303 | 304 | """ 305 | MAIN 306 | """ 307 | 308 | 309 | def main(): 310 | # Get the configs 311 | try: 312 | f = open(os.path.dirname(os.path.abspath(__file__)) + '/config.json', 'r') 313 | data = json.load(f) 314 | except: 315 | sys.exit(10) 316 | 317 | # Choose what to do 318 | switch = { 319 | '--hello': hello, 320 | '--query': query, 321 | '--code': code, 322 | '--auth': auth 323 | } 324 | 325 | if sys.argv[1] in switch: 326 | switch[sys.argv[1]](sys.argv, data) 327 | else: 328 | sys.exit(-1) 329 | 330 | 331 | if __name__ == "__main__": 332 | main() 333 | --------------------------------------------------------------------------------