#!/usr/bin/python3.8 # # Monitor the filesystem for content changes, to push them into Plex. # This is specific to our home server: # - one big directory with directories of content # - videos reside in those directories, along with markers for the # libraries the video(s) should reside within # - Plex looks at "library" directories # - create/manage symlinks from the libraries to the source content # import os.path import configparser import email.message import subprocess import time import inotify_simple as inotify CONFIG_FNAME = '/etc/homesvr/mediamgmt.conf' # Default plex library, if a video subdir isn't marked. DEFAULT_LIB = 'movies' # The command-line to send an email based on STDIN. SENDMAIL_CMD = ('/usr/sbin/sendmail', '-t', '-oi') # Flags for the base video directory, and its subdirs _DEPARTED = ( inotify.flags.DELETE | inotify.flags.MOVED_FROM ) _ARRIVED = ( inotify.flags.CREATE | inotify.flags.MOVED_TO ) FLAGS_BASE = _DEPARTED | _ARRIVED FLAGS_SUBDIR = _DEPARTED | _ARRIVED # The video extensions we'll look for. VIDEO_EXT = { '.mp4', '.m4v', '.mkv', } # Never hold longer than N milliseconds. We may want to send email. EVENT_TIMEOUT = 5 * 60 * 1000 # 5 minutes EVENT_TIMEOUT = 10 * 1000 # Once an event is thrown, wait N more seconds for additional events. ### this does not seem to work. sigh. EVENT_READ_DELAY = 5 * 60 # 5 minutes # How often should we send email? (in seconds) EMAIL_FREQUENCY = 30 * 60 # every 30 minutes EMAIL_FREQUENCY = 20 def run_monitor(cfg): gcfg = GeneralConfig(cfg) video_dir = gcfg.video_dir default_lib = getattr(gcfg, 'default', DEFAULT_LIB) #print(video_dir, default_lib) library_dir = gcfg.library_dir # All our libraries. name : lib-full-path libdirs = dict((n, os.path.join(library_dir, d)) for n, d in cfg.items('libraries')) #print('LIBDIRS:', libdirs) # All of the watches we have installed. subdir : watch-descriptor watches = { } i = inotify.INotify() # Observe every subdirectory in the video directory. subdirs = { } for name in os.listdir(video_dir): path = os.path.join(video_dir, name) if os.path.isdir(path): watches[name] = i.add_watch(path, FLAGS_SUBDIR) subdirs[name] = VideoSubDir(path, libdirs, default_lib) # Before we start, let's make sure that the links in the target # area are correct, and that all are present. notes = full_sync(video_dir, subdirs, libdirs) if notes: # Append a marker, only if some sync occurred. notes.append('-- END OF SYNC --') last_email = 0 def find_subdir(wd): "Find a subdir name and VideoSubDir, given a watch descriptor." return next((n, subdirs[n]) for n, d in watches.items() if d == wd) # Add this watch *after* the listdir() above. The VD watch descriptor # is special, so we can add/remove subdirs and watches within. vd = i.add_watch(video_dir, FLAGS_BASE) watches['.'] = vd while True: #print('WATCHING', len(watches), f'DIRS; vd={vd}') # Block, to receive at least one event. #t0 = time.time() events = i.read(timeout=EVENT_TIMEOUT, read_delay=EVENT_READ_DELAY) #print('READ TIME:', time.time() - t0) for event in events: # Indicates a watch was removed. We can (heh) ignore this, # and wait for the _REMOVED event. if event.mask & inotify.flags.IGNORED: continue notes.append(str(event)) for flag in inotify.flags.from_mask(event.mask): notes.append(' ' + str(flag)) base, ext = os.path.splitext(event.name) if event.wd == vd: if not (event.mask & inotify.flags.ISDIR): # We only care about subdirs. continue if event.mask & _ARRIVED: notes.append(f'ADDED "{event.name}" to videos') path = os.path.join(video_dir, event.name) # Begin watching this subdir. watches[event.name] = i.add_watch(path, FLAGS_SUBDIR) # Scan subdir, add links (if the subdir is MOVED_TO here, then # it may have videos already). vsd = VideoSubDir(path, libdirs, default_lib) subdirs[event.name] = vsd # Add links for all videos found, in the libs found. notes.extend(vsd.add_links()) elif event.mask & _DEPARTED: notes.append(f'REMOVED "{event.name}" from videos') # The subdir is no longer watched. The watches will be # auto-removed, so we don't have to worry about that. del watches[event.name] # Remove links for any files in this subdir. notes.extend(subdirs[event.name].remove_links()) del subdirs[event.name] else: notes.append('ERROR: unhandled change.') else: subdir, vsd = find_subdir(event.wd) islib = (event.name in libdirs) if event.mask & _ARRIVED: print(f'SEEN: ADDED "{event.name}" within "{subdir}"') if islib: notes.extend(vsd.add_lib(event.name)) elif is_video(event.name): notes.extend(vsd.add_video(event.name)) elif event.mask & _DEPARTED: print(f'SEEN: REMOVED "{event.name}" within "{subdir}"') if islib: notes.extend(vsd.remove_lib(event.name)) elif is_video(event.name): notes.extend(vsd.remove_video(event.name)) else: notes.append('ERROR: unhandled change.') # Remove None values. Leave empty strings. notes = list(filter(None.__ne__, notes)) if notes and time.time() - last_email > EMAIL_FREQUENCY: # Format, and output. print('\n'.join(notes)) notify(gcfg, 'library changes', '\n'.join(notes)) # Reset data for emails notes = [ ] last_email = time.time() def full_sync(video_dir, subdirs, libdirs): notes = [ ] # Check each symlink present, and remove all invalid links. for libname, libdir in libdirs.items(): for fname in os.listdir(libdir): destpath = os.path.join(libdir, fname) #print('CHECKING:', destpath) if not os.path.islink(destpath): continue if not os.path.exists(destpath): notes.append(f'STALE, REMOVING: "{destpath}"') os.unlink(destpath) continue srcpath = os.readlink(destpath) #print(' -->', srcpath) if not os.path.isabs(srcpath) \ or os.path.commonprefix((video_dir, srcpath)) != video_dir: # Not one of ours. Ignore it. continue relpath = os.path.relpath(srcpath, video_dir) subdir, fname = os.path.split(relpath) #print(' -->', relpath, subdir, fname) vds = subdirs[subdir] # must be present, due to .exists() above if libname not in vds.which_libs() or fname not in vds.videos: # The symlink points to an existing file in this subdir. However, # the link is residing in a library not associated with this # subdir, or the specified file is not a recognized file. Thus, # the symlink is incorrect. notes.append(f'INCORRECT, REMOVING: "{destpath}"; pointed at "{relpath}"') os.unlink(destpath) # Add all the links, ignoring already-present links (which above has # determined are valid). for vds in subdirs.values(): notes.extend(vds.add_links(ignore_err=True)) return notes class VideoSubDir: def __init__(self, srcdir, libdirs, default_lib): self.srcdir = srcdir self.libdirs = libdirs self.default_lib = default_lib self.libs = set() self.videos = set() # Initial scan to load information. libnames = set(libdirs) for fname in os.listdir(srcdir): if fname in libnames: self.libs.add(fname) elif is_video(fname): self.videos.add(fname) #print(f'VSD: "{srcdir}" libs:{self.libs} videos:{self.videos}') def which_libs(self): # If no libraries are defined, then use the default library. return self.libs or { self.default_lib } def add_lib(self, libname): if not self.libs: if libname == self.default_lib: # Links are already present in the default lib. Just add # this library explicitly, and call it done. self.libs.add(libname) return [ ] # Remove links from the default library, then add to the new. notes = [ self._remove_link(self.default_lib, v) for v in self.videos ] else: notes = [ ] self.libs.add(libname) notes.extend(self._add_link(libname, v) for v in self.videos) return notes def remove_lib(self, libname): self.libs.remove(libname) if not self.libs: if libname == self.default_lib: # The specified library is the default library, so all the # links exist. Call it done. return [ ] # There are no more libraries defined, so add videos to the # default library. notes = [ self._add_link(self.default_lib, v) for v in self.videos ] else: notes = [ ] # Remove links from the specified library. notes.extend(self._remove_link(libname, v) for v in self.videos) return notes def add_video(self, fname): self.videos.add(fname) return [ self._add_link(l, fname) for l in self.which_libs() ] def remove_video(self, fname): self.videos.remove(fname) return [ self._remove_link(l, fname) for l in self.which_libs() ] def add_links(self, ignore_err=False): notes = [ ] for libname in self.which_libs(): notes.extend(self._add_link(libname, v, ignore_err) for v in self.videos) return notes def remove_links(self, ignore_err=False): notes = [ ] for libname in self.which_libs(): notes.extend(self._remove_link(libname, v, ignore_err) for v in self.videos) return notes def _add_link(self, libname, fname, ignore_err=False): srcpath = os.path.join(self.srcdir, fname) destpath = os.path.join(self.libdirs[libname], fname) try: os.symlink(srcpath, destpath) except FileExistsError: if ignore_err: return None return f'LINK EXISTS: {destpath}' return f'ADDED LINK: {destpath}' def _remove_link(self, libname, fname, ignore_err=False): destpath = os.path.join(self.libdirs[libname], fname) try: os.unlink(destpath) except FileNotFoundError: if ignore_err: return None return f'MISSING LINK: {destpath}' return f'REMOVED LINK: {destpath}' def is_video(fname): return os.path.splitext(fname)[1] in VIDEO_EXT def notify(gcfg, subject, text): msg = email.message.EmailMessage() msg.set_content(text) msg['Subject'] = subject msg['From'] = gcfg.from_addr msg['To'] = gcfg.to_addr subprocess.run(SENDMAIL_CMD, input=msg.as_bytes(), check=True) class GeneralConfig: def __init__(self, cfg): self.__dict__.update(cfg.items('general')) def main(): cfg = configparser.ConfigParser() cfg.read(CONFIG_FNAME) run_monitor(cfg) if __name__ == '__main__': main()