├── CHANGELOG.md ├── README.md ├── init.d └── plex-inotify └── plex-inotify.py /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.4 4 | 5 | * Replaced some old Python2 code in the error-handling routine that wasn't compatible with Python3. 6 | 7 | ## v1.3 8 | 9 | * Added option to provide a Plex Token which is required if the primary PMS account has a PIN, or if you have multiple user accounts 10 | 11 | ## v1.2 12 | 13 | * Use secure connections by default, with option to disable 14 | 15 | ## v1.1 16 | 17 | * Changed path mapping to map to PMS library names 18 | 19 | ## v1.0 20 | 21 | * Initial release 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plex iNotifier 2 | 3 | Plex is an amazing piece of software. One issue with Plex Media Server, though, is that it doesn't auto-update the library when there are changes on connected network shares. This can be annoying if you run your PMS on a different machine (e.g. Nvidia Shield, Mac Mini, etc) than where your media library is stored (e.g. NAS), as you either have to trigger an update on PMS manually when adding/moving/renaming content or wait for the library update interval to kick in. 4 | 5 | So, I wrote a script to automate that (borrowed heavily from [here](https://codesourcery.wordpress.com/2012/11/29/more-on-the-synology-nas-automatically-indexing-new-files/)). 6 | 7 | The script works by tying into inotify on the NAS, which is a kernel subsystem that notices changes to the filesystem and reports those changes to applications. The script uses inotify to monitor the media directories on the NAS for changes, then connects to the remote Plex Server's web API to find the appropriate media section to refresh. If it finds a matching section, it uses the web API to send an update command to that section. 8 | 9 | 10 | ## Installation 11 | 12 | 1. Make sure you have "Python3" installed. 13 | 14 | 2. You'll need to install the "pynotify" Python module. The easiest way is to install the Python EasyInstall utility; Shell into your server, and run: 15 | `wget https://bootstrap.pypa.io/ez_setup.py -O - | python3` then run: `easy_install pyinotify` 16 | 17 | 3. Save the `plex-inotify.py` script somewhere on your NAS/fileserver, e.g. `/usr/local/bin` 18 | 19 | 4. Edit the `plex_server_host` variable near the top of your script to match the IP address of your Plex Server. If you have local DNS resolution, you can use a hostname instead. 20 | 21 | 5. Edit the `path_maps` variable to map the local paths of the media shares on your fileserver to their corresponding library names in your Plex Media Server. 22 | 23 | 6. You should change the `daemonize` variable to `False` for testing purposes until you're sure that everything is working properly. 24 | 25 | 7. Try running the script with `python3 plex-inotify.py`, and if all goes well, it will load up without errors :) 26 | 27 | If you set `daemonize` to `True`, then the script will fork itself into a background task when you run it. It will stay running even if you log out of the shell. 28 | 29 | ## Troubleshooting 30 | 31 | * If you see a bunch of errors that say something like `Errno=No space left on device (ENOSPC)`, then your inotify watcher limit is too low. Run `sysctl -n -w fs.inotify.max_user_watches=16384` and then try again. Keep raising the number until the errors go away. 32 | 33 | * If you see an error that says `Errno=No such file or directory (ENOENT)`, then you didn't configure your `paths_maps` properly. Make sure each entry in the list is a local path to your media on the NAS and then the corresponding library/section name on your PMS. 34 | 35 | * If you're getting an error that says `urllib.error.HTTPError: HTTP Error 401: Unauthorized`, then you need to set the `plex_account_token` variable. Follow [this link](https://support.plex.tv/hc/en-us/articles/204059436-Finding-your-account-token-X-Plex-Token) for instructions on how to get your account token. Make sure that when you're setting the variable, you wrap the token in quotes, like this: `plex_account_token = 'A2ekcFXjzPqmefBpv8da'` 36 | 37 | Let me know if you find this useful! -------------------------------------------------------------------------------- /init.d/plex-inotify: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # plex-inotify 3 | 4 | case "$1" in 5 | start|"") 6 | 7 | #start the inotify monitoring daemon 8 | python3 /usr/local/bin/plex-inotify.py 9 | 10 | ;; 11 | restart|reload|force-reload) 12 | echo "Error: argument '$1' not supported" >&2 13 | exit 3 14 | ;; 15 | stop) 16 | kill `cat /var/run/plex-inotify.pid` 17 | ;; 18 | *) 19 | echo "Usage: plex-inotify [start|stop]" >&2 20 | exit 3 21 | ;; 22 | esac -------------------------------------------------------------------------------- /plex-inotify.py: -------------------------------------------------------------------------------- 1 | # PLEX NOTIFIER SCRIPT v1.4 2 | # Written by Talisto: https://forums.plex.tv/profile/talisto 3 | # Modified heavily from https://codesourcery.wordpress.com/2012/11/29/more-on-the-synology-nas-automatically-indexing-new-files/ 4 | 5 | ################################################### 6 | # MODIFY VARIABLES HERE 7 | ################################################### 8 | 9 | # Plex Server IP or hostname 10 | plex_server_host = '192.168.0.10' 11 | 12 | # Plex Server port 13 | plex_server_port = 32400 14 | 15 | # Plex account token; only required if your primary account has a PIN enabled, 16 | # or if you have multiple users. Instructions how to get your token: 17 | # https://support.plex.tv/hc/en-us/articles/204059436-Finding-your-account-token-X-Plex-Token 18 | plex_account_token = False 19 | 20 | # Map the fileserver's local paths to their associated Plex Media Server library names 21 | path_maps = { 22 | '/volume1/video/TV Shows': 'TV Shows', 23 | '/volume1/video/Movies': 'Movies', 24 | '/volume1/music': 'Music', 25 | } 26 | 27 | # Allowed file extensions 28 | allowed_exts = [ 29 | 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 30 | 'mp3', 'flac', 'aac', 'wma', 'ogg', 'ogv', 'wav', 'wma', 'aiff', 31 | 'mpg', 'mp4', 'avi', 'mkv', 'm4a', 'mov', 'wmv', 'm2v', 'm4v', 'vob' 32 | ] 33 | 34 | # Log file 35 | log_file_path = '/var/log/plex-inotify.log' 36 | 37 | # PID file 38 | pid_file_path = '/var/run/plex-inotify.pid' 39 | 40 | # Daemonize (run in the background) or not 41 | daemonize = False 42 | 43 | # connect to PMS using HTTPS instead of HTTP 44 | # NOTE: The HTTPS connection does not validate the SSL certificate!! 45 | secure_connection = True 46 | 47 | ################################################### 48 | # YOU SHOULDN'T NEED TO TOUCH ANYTHING BELOW HERE 49 | ################################################### 50 | 51 | import pyinotify 52 | import sys 53 | import os.path 54 | from subprocess import call 55 | import signal 56 | import fnmatch 57 | import urllib.request 58 | import ssl 59 | import xml.etree.ElementTree as ET 60 | import json 61 | 62 | ################################################### 63 | # CLASSES / FUNCTIONS 64 | ################################################### 65 | 66 | class EventHandler(pyinotify.ProcessEvent): 67 | 68 | def __init__(self, host, port, protocol, token, libraries, allowed_exts): 69 | self.modified_files = set() 70 | self.plex_host = host 71 | self.plex_port = port 72 | self.plex_account_token = token 73 | self.protocol = protocol 74 | self.libraries = libraries 75 | self.allowed_exts = allowed_exts 76 | 77 | def process_IN_CREATE(self, event): 78 | self.process_path(event, 'CREATE') 79 | 80 | def process_IN_MOVED_TO(self, event): 81 | self.process_path(event, 'MOVED TO') 82 | 83 | def process_IN_MOVED_FROM(self, event): 84 | self.process_path(event, 'MOVED FROM') 85 | 86 | def process_IN_DELETE(self, event): 87 | self.process_path(event, 'DELETE') 88 | 89 | def process_IN_MODIFY(self, event): 90 | if self.is_allowed_path(event.pathname, event.dir): 91 | self.modified_files.add(event.pathname) 92 | 93 | def process_IN_CLOSE_WRITE(self, event): 94 | # ignore close_write unlesss the file has previously been modified. 95 | if (event.pathname in self.modified_files): 96 | self.process_path(event, 'WRITE') 97 | 98 | def process_path(self, event, type): 99 | if self.is_allowed_path(event.pathname, event.dir): 100 | log("Notification: %s (%s)" % (event.pathname, type)) 101 | 102 | for path in list(self.libraries.keys()): 103 | if fnmatch.fnmatch(event.pathname, path + "/*"): 104 | log("Found match: %s matches Plex section ID: %d" % ( 105 | event.pathname, 106 | self.libraries[path] 107 | )) 108 | self.update_section(self.libraries[path]) 109 | 110 | # Remove from list of modified files. 111 | try: 112 | self.modified_files.remove(event.pathname) 113 | except KeyError as err: 114 | # Don't care. 115 | pass 116 | else: 117 | log("%s is not an allowed path" % event.pathname) 118 | 119 | def update_section(self, section): 120 | log('Updating section ID %d' % (section)) 121 | response = url_open("%s://%s:%d/library/sections/%d/refresh" % ( 122 | self.protocol, 123 | self.plex_host, 124 | self.plex_port, 125 | section 126 | ), self.plex_account_token) 127 | 128 | def is_allowed_path(self, filename, is_dir): 129 | # Don't check the extension for directories 130 | if not is_dir: 131 | ext = os.path.splitext(filename)[1][1:].lower() 132 | if ext not in self.allowed_exts: 133 | return False 134 | if filename.find('@eaDir') > 0: 135 | return False 136 | return True 137 | 138 | def log(text): 139 | if not daemonize: 140 | print(text) 141 | log_file.write(text + "\n") 142 | log_file.flush() 143 | 144 | def signal_handler(signal, frame): 145 | log("Exiting") 146 | sys.exit(0) 147 | 148 | # custom urlopen() function to bypass SSL certificate validation 149 | def url_open(url, token): 150 | if token: 151 | req = urllib.request.Request(url + '?X-Plex-Token=' + token) 152 | else: 153 | req = urllib.request.Request(url) 154 | if url.startswith('https'): 155 | ctx = ssl.create_default_context() 156 | ctx.check_hostname = False 157 | ctx.verify_mode = ssl.CERT_NONE 158 | return urllib.request.urlopen(req, context=ctx) 159 | else: 160 | return urllib.request.urlopen(req) 161 | 162 | ################################################### 163 | # MAIN PROGRAM STARTS HERE 164 | ################################################### 165 | 166 | log_file = open(log_file_path, 'a') 167 | 168 | watch_events = pyinotify.IN_CLOSE_WRITE \ 169 | | pyinotify.IN_DELETE \ 170 | | pyinotify.IN_CREATE \ 171 | | pyinotify.IN_MOVED_TO \ 172 | | pyinotify.IN_MOVED_FROM 173 | 174 | signal.signal(signal.SIGTERM, signal_handler) 175 | 176 | if secure_connection: 177 | protocol = 'https' 178 | else: 179 | protocol = 'http' 180 | 181 | libraries = {} 182 | response = url_open( 183 | "%s://%s:%d/library/sections" % ( 184 | protocol, 185 | plex_server_host, 186 | plex_server_port 187 | ), 188 | plex_account_token 189 | ) 190 | tree = ET.fromstring(response.read().decode("utf-8")) 191 | for directory in tree: 192 | for path, name in path_maps.items(): 193 | if directory.attrib['title'] == name: 194 | libraries[path] = int(directory.attrib['key']) 195 | log("Got Plex libraries: " + json.dumps(libraries)) 196 | 197 | handler = EventHandler( 198 | plex_server_host, 199 | plex_server_port, 200 | protocol, 201 | plex_account_token, 202 | libraries, 203 | allowed_exts 204 | ) 205 | wm = pyinotify.WatchManager() 206 | notifier = pyinotify.Notifier(wm, handler) 207 | 208 | log('Adding directories to inotify watch') 209 | 210 | wdd = wm.add_watch( 211 | list(libraries.keys()), 212 | watch_events, 213 | rec=True, 214 | auto_add=True 215 | ) 216 | 217 | log('Starting loop') 218 | 219 | try: 220 | notifier.loop(daemonize=daemonize, pid_file=pid_file_path) 221 | except pyinotify.NotifierError as err: 222 | print(err, file=sys.stderr) 223 | --------------------------------------------------------------------------------