├── .gitignore ├── requirements.txt ├── config.yml.default ├── README.md └── plex_rcs.py /.gitignore: -------------------------------------------------------------------------------- 1 | config.yml 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | plexapi 2 | pyyaml 3 | sh 4 | -------------------------------------------------------------------------------- /config.yml.default: -------------------------------------------------------------------------------- 1 | # config.yml - for plex rclone cache scaner (plex_rcs) 2 | # 3 | # Options are pretty straight forward. This program 4 | # MUST run on the same host as your Plex server and yhour 5 | # Plex server MUST run in a docker container. 6 | # 7 | # host: this is the hostname or IP of your plex server 8 | # (should not change from localhost) 9 | # port: port that your plex server runs on (default 32400) 10 | # token: Your Plex-X-Token master token 11 | # backend: cache backend (either cache or vfs) 12 | # docker: true/false whether Plex runs in a docker container 13 | # container: Name of your plex container (default: plex) 14 | # media_root: this is the root folder that contains your media 15 | # 16 | # More on media_root: 17 | # 18 | # When rclone detects a new file, it will show up in the log as the sub-folder 19 | # of the rclone cache remote. Example would be "tvshows/Grimm/Season 1/". This 20 | # setting maps that to where the script can find that folder in your docker container or 21 | # on your system (if not using docker) 22 | # example: "/media/tvshows/Grimm/Season 1/". You may need to play around with this. 23 | 24 | plex_rcs: 25 | host: localhost 26 | port: 32400 27 | token: TOKEN 28 | backend: vfs 29 | docker: true 30 | container: plex 31 | media_root: /media 32 | env: 33 | LD_LIBRARY_PATH: "/usr/lib/plexmediaserver" 34 | PLEX_MEDIA_SERVER_APPLICATION_SUPPORT_DIR: "/var/lib/plexmediaserver" 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plex rclone cache scanner (plex_rcs) 2 | 3 | A small little program that will monitor an rclone log file waiting for notices of file cache expiration. Upon receiving a notice, a local Plex scan of that folder will be triggered and new media will appear in Plex almost instantly. 4 | 5 | This is useful for people who run Plex Media Server on a different server than Sonarr/Radarr/etc. It's possible to have media appear in your Plex server within 5-10 minutes of downloading using this program, along with an rclone `--cache-tmp-wait-time 5m`. 6 | 7 | ## Requirements 8 | 9 | 1. Python 3+ 10 | 2. Your rclone cache mount must include `--log-level INFO` 11 | 3. Your rclone cache mount must include `--syslog` **OR** `--log-file /path/to/file.log` 12 | 13 | ## Installation 14 | 15 | 1. Clone this repo: `git clone https://github.com/stokkes/plex_rcs.git` 16 | 2. Install the requirements: `sudo pip3 install -r requirements.txt` 17 | 18 | ## Configuration 19 | 20 | 1. Copy the `config.yml.default` to `config.yml` 21 | 2. Edit `config.yml` to include your [X-Plex-Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/), set your `media_root` setting and any other settings. _See below for more information on the `media_root` setting_. 22 | 23 | ## Running plex_rcs 24 | 25 | There are two ways I recommend you run this. Using `screen` or using the included `plex_rcs.service` systemd service _(coming soon)_. 26 | 27 | ### Using `screen` 28 | 29 | Execute the program using screen: 30 | 31 | `/usr/bin/screen -dmS plexrcs /path/to/plex_rcs/plex_rcs.py` 32 | 33 | To view the console: `screen -r plexrcs` 34 | 35 | ### Using systemd 36 | 37 | _**Coming soon**_ 38 | 39 | 1. Edit the included `plex_rcs.service` file and change the path to where the `plex_rcs.py` file is located 40 | 2. Copy the systemd file to `/etc/systemd/service`: `sudo cp plex_rcs.service /etc/systemd/service` 41 | 3. Reload systemd: `sudo systemctl daemon-reload` 42 | 4. Enable the service [auto-starts on boot]: `sudo systemctl enable plex_rcs` 43 | 5. Start the service: `sudo systemctl start plex_rcs` 44 | 45 | ## More info on `media_root` configuration setting 46 | 47 | This setting may be tricky to figure out at the first glance, but it is critical to get `plex_rcs` working properly. 48 | 49 | The value of this setting is the folder **inside your docker container** (if using docker) that contains all your media. Typically, this would be in `/media`. If not using docker, this will likely be the path to your rclone `cache` remote mount, i.e.: `/mnt/media` 50 | 51 | However, at this time there is 1 requirement: 52 | 53 | The root of your rclone `cache` remote (i.e.: `gdrive-cache:`) **must** contain all your media in sub-folders, so that the remote and the folder that is mounted inside your docker container/on your system both contain the same sub-folders. 54 | 55 | **How to test:** 56 | 57 | 1. `rclone lsd cache:` and 58 | 2. `docker exec -ti plex ls /media` (where `/media` is where your media is located inside your docker container) 59 | 3. If not using docker, `ls /path/to/rclone/cache/mount` 60 | 61 | If the result of these two folders yield the same sub-folders, then `plex_rcs` will work correctly. 62 | 63 | **What won't work** 64 | 65 | 1. `rclone lsd cache:` shows many different folders, not just media sub-folders 66 | 2. You've mounted the `cache:` remote to `/mnt/media` using something like `rclone mount cache:Media /mnt/media` 67 | 2. `docker exec -ti plex ls /media` shows only your media sub-folders 68 | 69 | I hope to build some logic to help figure this out, but don't hold your breath. 70 | 71 | ## Testing 72 | 73 | You can test `plex_rcs` if you use the built in /var/log/syslog monitoring by executing the following command (replace `tvshows` and the series/episode by your values): 74 | 75 | `logger "Apr 21 07:20:51 plex rclone[21009]: tvshows/Survivor/Season 20/Survivor - S20E01 - Episode.mkv: received cache expiry notification"` 76 | 77 | If you're monitoring the `plex_rcs` console, you should see activity: 78 | 79 | ``` 80 | Starting to monitor /var/log/syslog with pattern for rclone 81 | Match found: tvshows/Survivor/Season 20/Survivor - S20E01 - Episode.mkv 82 | Processing section 1, folder: /media/tvshows/Survivor/Season 20 83 | GUI: Scanning Survivor/Season 20 84 | ``` 85 | 86 | ## TODO 87 | 88 | * Support Plexdrive (analyis required) 89 | * Smarter logic to detect rclone cache root/docker media root 90 | * Logging to file 91 | -------------------------------------------------------------------------------- /plex_rcs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # 3 | # Helper script 4 | # 5 | import os 6 | import sys 7 | import re 8 | import argparse 9 | import yaml 10 | import time 11 | from datetime import datetime 12 | from subprocess import call 13 | from plexapi.myplex import PlexServer 14 | from sh import tail 15 | 16 | def config(file): 17 | global plex, cfg 18 | 19 | with open(file, 'r') as ymlfile: 20 | cfg = yaml.load(ymlfile)['plex_rcs'] 21 | 22 | try: 23 | plex = PlexServer("http://{0}:{1}".format(cfg['host'], cfg['port']), cfg['token']) 24 | if args.test: 25 | print("Config OK. Successfully connected to Plex server on {0}:{1}".format(cfg['host'], cfg['port'])) 26 | except: 27 | sys.exit("Failed to connect to plex server {0}:{1}.".format(cfg['host'], cfg['port'])) 28 | 29 | def build_sections(): 30 | global paths 31 | 32 | # Build our library paths dictionary 33 | for section in plex.library.sections(): 34 | for l in plex.library.section(section.title).locations: 35 | paths.update({l:section.key}) 36 | 37 | def scan(folder): 38 | 39 | if cfg['media_root'].rstrip("/") in folder: 40 | directory = args.directory 41 | else: 42 | directory = "{0}/{1}".format(cfg['media_root'].rstrip("/"), folder) 43 | 44 | # Match the new file with a path in our library 45 | # and trigger a scan via a `docker exec` call 46 | found = False 47 | 48 | for p in paths: 49 | if p in directory: 50 | found = True 51 | section_id = paths[p] 52 | print("Processing section {0}, folder: {1}".format(section_id, directory)) 53 | 54 | if cfg['docker']: 55 | try: 56 | call(["/usr/bin/docker", "exec", "-i", cfg['container'], "/usr/lib/plexmediaserver/Plex Media Scanner", "--scan", "--refresh", "--section", section_id, "--directory", directory]) 57 | except: 58 | print("Error executing docker command") 59 | else: 60 | os.environ['LD_LIBRARY_PATH'] = cfg['env']['LD_LIBRARY_PATH'] 61 | os.environ['PLEX_MEDIA_SERVER_APPLICATION_SUPPORT_DIR'] = cfg['env']['PLEX_MEDIA_SERVER_APPLICATION_SUPPORT_DIR'] 62 | try: 63 | call(["{0}/Plex Media Scanner".format(cfg['env']['LD_LIBRARY_PATH']), "--scan", "--refresh", "--section", section_id, "--directory", directory], env=os.environ) 64 | except: 65 | print("Error executing {0}/Plex Media Scanner".format(cfg['env']['LD_LIBRARY_PATH'])) 66 | 67 | if not found: 68 | print("Scanned directory '{0}' not found in Plex library".format(args.directory)) 69 | 70 | def tailf(logfile): 71 | print("Starting to monitor {0} with pattern for rclone {1}".format(logfile, cfg['backend'])) 72 | 73 | # Validate which backend we're using 74 | if cfg['backend'] == 'cache': 75 | # Use cache backend 76 | for line in tail("-Fn0", logfile, _iter=True): 77 | if re.match(r".*(mkv:|mp4:|mpeg4:|avi:) received cache expiry notification", line): 78 | f = re.sub(r"^(.*rclone\[[0-9]+\]: )([^:]*)(:.*)$",r'\2', line) 79 | print("Detected new file: {0}".format(f)) 80 | scan(os.path.dirname(f)) 81 | 82 | elif cfg['backend'] == 'vfs': 83 | # Use vfs backend 84 | timePrev = '' 85 | for line in tail("-Fn0", logfile, _iter=True): 86 | if re.match(r".*: forgetting directory cache", line): 87 | f = re.sub(r"^.*\s:\s(.*):\sforgetting directory cache",r'\1', line) 88 | timeCurr = re.sub(r"^.*\s([0-9]+:[0-9]+:[0-9]+)\s.*\s:\s.*:\sforgetting directory cache",r'\1', line) 89 | 90 | if timeCurr != timePrev: 91 | print("Detected directory cache expiration: {0}".format(f)) 92 | scan(os.path.dirname(f)) 93 | timePrev = timeCurr 94 | 95 | 96 | if __name__ == "__main__": 97 | 98 | parser = argparse.ArgumentParser(prog="plex_rcs_helper.py", description="Small helper script to update a Plex library section by scanning a specific directory.") 99 | parser.add_argument("-d", "--directory", dest="directory", metavar="directory", help="Directory to scan") 100 | parser.add_argument("-l", "--logfile", dest="logfile", metavar="logfile", help="Log file to monitor (default /var/log/syslog)") 101 | parser.add_argument("-c", "--config", dest="config", metavar="config", help="config file") 102 | parser.add_argument("--test", action='store_true', help="Test config") 103 | args = parser.parse_args() 104 | 105 | # Initialize our paths dict 106 | paths = {} 107 | 108 | # Configuration file 109 | if args.config: 110 | cf = args.config 111 | if not os.path.isfile(cf): 112 | print("Configuration file '{0}' does not exist.".format(args.config)) 113 | sys.exit(1) 114 | else: 115 | cf = "{0}/config.yml".format(os.path.dirname(os.path.realpath(__file__))) 116 | if not os.path.isfile(cf): 117 | print("Configuration file '{0}' does not exist.".format(os.path.dirname(os.path.realpath(__file__)))) 118 | sys.exit(1) 119 | 120 | # Logfile 121 | if args.logfile: 122 | lf = args.logfile 123 | if not os.path.isfile(cf): 124 | print("Log file '{0}' does not exist.".format(args.logfile)) 125 | sys.exit(1) 126 | else: 127 | lf = "/var/log/syslog" 128 | if not os.path.isfile(cf): 129 | print("Log file '/var/log/syslog' does not exist.".format(args.config)) 130 | sys.exit(1) 131 | 132 | # Main 133 | if args.test: 134 | config(cf) 135 | elif args.directory: 136 | # Build config 137 | config(cf) 138 | 139 | # Build sections 140 | build_sections() 141 | 142 | # Scan directory 143 | scan(args.directory) 144 | else: 145 | # Build config 146 | config(cf) 147 | 148 | # Build sections 149 | build_sections() 150 | 151 | # Scan directory 152 | tailf(lf) 153 | --------------------------------------------------------------------------------