package net.pms.util;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.*;
import static java.nio.file.FileVisitOption.*;
import static java.nio.file.StandardWatchEventKinds.*;
import java.nio.file.attribute.*;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Iterator;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import org.apache.commons.io.FilenameUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* An abstraction of the Java 7 nio WatchService api, which monitors native system
* file-change notifications as opposed to directly polling or examining files.
*/
public class FileWatcher {
private static final Logger LOGGER = LoggerFactory.getLogger(FileWatcher.class);
public static interface Listener {
/**
* A user-defined callback for receiving file change notifications.
*
* @param filename The changed filepath, relative or absolute depending on the original filespec.
* @param event The change itself: 'ENTRY_CREATE' 'ENTRY_MODIFY' or 'ENTRY_DELETE'.
* @param watch The original user-supplied watch object that triggered the match.
* @param isDir Whether the changed file is a directory.
*/
public void notify(String filename, String event, FileWatcher.Watch watch, boolean isDir);
}
/**
* A file watchpoint.
*/
public static class Watch {
public String fspec;
private WeakReference<Listener> listener;
private WeakReference<Object> item;
public int flag;
private PathMatcher matcher;
// Convenience constructors
public Watch(String fspec, Listener listener) {
this(fspec, listener, null, 0);
}
public Watch(String fspec, Listener listener, Object item) {
this(fspec, listener, item, 0);
}
public Watch(String fspec, Listener listener, int flag) {
this(fspec, listener, null, flag);
}
/**
* Creates a file watchpoint.
*
* @param fspec The filespec describing what files to match. There are 2 patterns:
* - glob (default, forward slashes work in Windows too):
* foo/bar.jpg - a specific file.
* foo/* - any file in the foo directory.
* foo/bar/*.jpg - any jpg in the foo/bar directory.
* foo/**.png - any png in the foo directory or below.
* foo/*.{png,jpg} - any png or jpg in the foo directory.
* - regex (must be prefixed with 'regex:'):
* TODO - presumably any regex string.
* For more on syntax see {@link java.nio.file.FileSystem#getPathMatcher(String)}.
* @param listener The user-defined callback.**
* @param item A user Object to attach to this watchpoint.**
* @param flag A user constant to attach to this watchpoint.
*
* @implNote ** Note that {@code listener} and {@code item} are held as weak references
* and will not persist if anonymously inlined in the constructor call.
*/
public Watch(String fspec, Listener listener, Object item, int flag) {
// Make sure we have double-backslashes in Windows paths
this.fspec = fspec.replace("\\\\", "\\").replace("\\", "\\\\");
this.listener = new WeakReference<>(listener);
this.item = (item != null) ? new WeakReference<>(item) : null;
this.flag = flag;
}
public void init(Path dir) {
// Assume glob pattern if no prefix
String match = (fspec.startsWith("glob:") || fspec.startsWith("regex:")) ? fspec : ("glob:" + fspec);
matcher = dir.getFileSystem().getPathMatcher(match);
}
public Object getItem() {
return (item != null) ? item.get() : null;
}
@Override
public boolean equals(Object o) {
if (o == null || !(o instanceof Watch)) {
return false;
}
Watch other = (Watch) o;
return listener.get() == other.listener.get() &&
(fspec == other.fspec || (fspec != null && fspec.equals(other.fspec))) &&
(item == other.item || (item != null && other.item != null && (item.get() == other.item.get() || item.get().equals(other.item.get())))) &&
flag == other.flag;
}
@Override
public int hashCode() {
return fspec.hashCode() + listener.hashCode();
}
public static boolean isRecursive(Watch w) {
// FIXME: How to detect recursion in 'regex:' syntax?
return w.fspec.contains("**") && !w.fspec.startsWith("regex:");
}
public static boolean isValid(Watch w) {
// Not valid if either the listener or item no longer exist
return w.listener.get() != null || (w.item != null && w.item.get() == null);
}
}
/**
* Add a file watchpoint to the Watch Service.
*
* @param w The watch object.
*/
public static void add(Watch w) {
Path dir = Paths.get(FilenameUtils.getFullPath(w.fspec));
w.init(dir);
if (keys.contains(w)) {
// Ignore duplicates
return;
}
if (Watch.isRecursive(w)) {
addRecursive(w, dir);
} else {
add(w, dir);
}
}
/**
* Remove a file watchpoint from the Watch Service.
*
* @param w The watch object.
*/
public static boolean remove(Watch w) {
return keys.remove(w);
}
// Internals
/**
* A map of file watchpoints by watchkey.
*/
static class WatchMap extends HashMap<WatchKey, ArrayList<Watch>> {
private static final long serialVersionUID = 66052264663459389L;
public void put(WatchKey k, Watch w) {
if (!containsKey(k)) {
put(k, new ArrayList<Watch>());
}
get(k).add(w);
}
public boolean contains(Watch w) {
for (ArrayList<Watch> a : values()) {
if (a.contains(w)) {
return true;
}
}
return false;
}
public boolean remove(Watch w) {
for (WatchKey k : keySet()) {
ArrayList<Watch> a = get(k);
if (a.contains(w)) {
return a.remove(w);
}
}
return false;
}
}
private static WatchMap keys = new WatchMap();
private static WatchService watchService = null;
public static void add(Watch w, Path dir) {
if (watchService == null) {
start(dir);
}
try {
WatchKey key = dir.register(watchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE);
keys.put(key, w);
LOGGER.debug("Added file watch at {}: {}", dir, w.fspec);
} catch (Exception e) {
LOGGER.debug("Register error: " + e);
e.printStackTrace();
}
}
public static void addRecursive(final Watch w, Path dir) {
try {
Files.walkFileTree(dir, EnumSet.of(FOLLOW_LINKS), Integer.MAX_VALUE, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
add(w, dir);
return FileVisitResult.CONTINUE;
}
});
} catch (Exception e) {
LOGGER.debug("Recursion error: " + e);
e.printStackTrace();
}
}
private static void start(Path dir) {
// Start the service
try {
watchService = dir.getFileSystem().newWatchService();
} catch (Exception e) {
LOGGER.debug("Error creating WatchService: " + e);
e.printStackTrace();
}
// Watch for subscribed file events
new Thread(new Runnable() {
@Override
public void run() {
try {
do {
// take() will block until events occur in our subscribed directories
WatchKey key = watchService.take();
try {
// Wait a bit in case there are a few repeats
Thread.sleep(100);
} catch (InterruptedException e) { }
// Filter the received directory event(s)
for (WatchEvent<?> e : key.pollEvents()) {
final WatchEvent.Kind<?> kind = e.kind();
if (kind != OVERFLOW) {
WatchEvent<Path> event = (WatchEvent<Path>) e;
// Determine the actual file
Path dir = (Path) key.watchable();
final Path filename = dir.resolve(event.context());
final boolean isDir = Files.isDirectory(filename/*, NOFOLLOW_LINKS*/);
// See if we're watching for this specific file
for (Iterator<Watch> iterator = keys.get(key).iterator(); iterator.hasNext();) {
final Watch w = iterator.next();
if (!Watch.isValid(w)) {
LOGGER.debug("Deleting expired file watch at {}: {}", dir, w.fspec);
iterator.remove();
continue;
}
if (w.matcher.matches(filename)) {
// We have an event of interest
LOGGER.debug("{} (ct={}): {}", kind, event.count(), filename);
if (isDir && kind == ENTRY_CREATE && Watch.isRecursive(w)) {
// It's a new directory in a recursive scope,
// traverse it to include any subdirs
addRecursive(w, filename);
} else {
// It's a regular event, schedule a notice
notifier.schedule(new Notice(filename.toString(), kind.toString(), w, isDir),
kind == ENTRY_MODIFY ? 500 : 0);
}
}
}
}
}
// Reset and clean up
if (!key.reset()) {
keys.remove(key);
}
} while (!keys.isEmpty());
} catch (Exception e) {
LOGGER.debug("Event process error: " + e);
e.printStackTrace();
}
}
}, "File watcher").start();
}
/**
* A runnable self-removing file event notice.
*/
static class Notice implements Runnable {
String filename, kind;
Watch watch;
boolean isDir;
HashMap notifierQueue = null;
public Notice(String filename, String kind, Watch watch, boolean isDir) {
this.filename = filename;
this.kind = kind;
this.watch = watch;
this.isDir = isDir;
}
@Override
public void run() {
watch.listener.get().notify(filename, kind, watch, isDir);
notifierQueue.remove(this);
}
@Override
public boolean equals(Object o) {
if (o == null || !(o instanceof Notice)) {
return false;
}
Notice other = (Notice) o;
return filename.equals(other.filename) && kind.equals(other.kind) && watch.equals(other.watch);
}
@Override
public int hashCode() {
return (filename + kind).hashCode();
}
}
/**
* A delayed file event notice scheduler.
*/
static class Notifier extends ScheduledThreadPoolExecutor {
HashMap<Notice, ScheduledFuture<?>> queue = new HashMap<>();
public Notifier(final String name) {
super(5, new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread(r, name);
}
});
setRemoveOnCancelPolicy(true);
}
/**
* Notices can be delayed slightly to allow ongoing file events to catch-up and cancel
* earlier in-progress notifications until the event is completed. This prevents
* sending 1000s of ENTRY_MODIFY notices during a file copy in linux, for instance.
*
* @param notice The notice.
* @param delay The delay in milliseconds.
*/
public void schedule(Notice notice, long delay) {
// Put the notice in the queue
notice.notifierQueue = queue;
ScheduledFuture<?> superceded = queue.put(notice, schedule(notice, delay, TimeUnit.MILLISECONDS));
// And cancel its previous instance, if any
if (superceded != null) {
superceded.cancel(false);
}
}
}
static private Notifier notifier = new Notifier("File event");
}