Merge branch 'master' into rewrite
authorDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Thu, 10 Jan 2013 21:24:55 +0000 (22:24 +0100)
committerDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Fri, 11 Jan 2013 06:19:37 +0000 (07:19 +0100)
Conflicts:
src/main/java/net/pterodactylus/rhynodge/filters/KickAssTorrentsFilter.java

1  2 
src/main/java/net/pterodactylus/rhynodge/filters/KickAssTorrentsFilter.java
src/main/java/net/pterodactylus/rhynodge/filters/PirateBayFilter.java
src/main/java/net/pterodactylus/rhynodge/filters/TorrentSiteFilter.java
src/main/java/net/pterodactylus/rhynodge/states/TorrentState.java
src/main/java/net/pterodactylus/rhynodge/triggers/NewEpisodeTrigger.java
src/main/java/net/pterodactylus/rhynodge/triggers/NewTorrentTrigger.java
src/main/resources/chains/thepiratebay-example.json

index 11865e7,0000000..c368bf3
mode 100644,000000..100644
--- /dev/null
@@@ -1,168 -1,0 +1,106 @@@
- import static com.google.common.base.Preconditions.checkState;
- import java.net.URI;
- import java.net.URISyntaxException;
 +/*
 + * Rhynodge - KickAssTorrentsFilter.java - Copyright © 2013 David Roden
 + *
 + * This program is free software: you can redistribute it and/or modify
 + * it under the terms of the GNU General Public License as published by
 + * the Free Software Foundation, either version 3 of the License, or
 + * (at your option) any later version.
 + *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + *
 + * You should have received a copy of the GNU General Public License
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.rhynodge.filters;
 +
- import net.pterodactylus.rhynodge.State;
 +import net.pterodactylus.rhynodge.Filter;
- import net.pterodactylus.rhynodge.states.FailedState;
 +import net.pterodactylus.rhynodge.queries.HttpQuery;
- import net.pterodactylus.rhynodge.states.TorrentState.TorrentFile;
 +import net.pterodactylus.rhynodge.states.HtmlState;
 +import net.pterodactylus.rhynodge.states.TorrentState;
- public class KickAssTorrentsFilter implements Filter {
 +
 +import org.jsoup.nodes.Document;
 +import org.jsoup.nodes.Element;
 +import org.jsoup.select.Elements;
 +
 +/**
 + * {@link Filter} implementation that parses a {@link TorrentState} from an
 + * {@link HtmlState} which was generated by a {@link HttpQuery} to
 + * {@code kickasstorrents.ph}.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
-       public State filter(State state) {
-               if (!state.success()) {
-                       return FailedState.from(state);
-               }
-               checkState(state instanceof HtmlState, "state is not an HtmlState but a %s", state.getClass().getName());
-               /* get result table. */
-               Document document = ((HtmlState) state).document();
-               Elements mainTable = document.select("table.data");
-               if (mainTable.isEmpty()) {
-                       /* no main table? */
-                       return new FailedState();
-               }
-               /* iterate over all rows. */
-               TorrentState torrentState = new TorrentState();
-               Elements dataRows = mainTable.select("tr:gt(0)");
-               for (Element dataRow : dataRows) {
-                       String name = extractName(dataRow);
-                       String size = extractSize(dataRow);
-                       String magnetUri = extractMagnetUri(dataRow);
-                       String downloadUri;
-                       int fileCount = extractFileCount(dataRow);
-                       int seedCount = extractSeedCount(dataRow);
-                       int leechCount = extractLeechCount(dataRow);
-                       try {
-                               downloadUri = new URI(((HtmlState) state).uri()).resolve(extractDownloadUri(dataRow)).toString();
-                               TorrentFile torrentFile = new TorrentFile(name, size, magnetUri, downloadUri, fileCount, seedCount, leechCount);
-                               torrentState.addTorrentFile(torrentFile);
-                       } catch (URISyntaxException use1) {
-                               /* ignore; if uri was wrong, we wouldn’t be here. */
-                       }
-               }
-               return torrentState;
++public class KickAssTorrentsFilter extends TorrentSiteFilter {
++
++      //
++      // TORRENTSITEFILTER METHODS
++      //
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
-       //
-       // STATIC METHODS
-       //
++      protected Elements getDataRows(Document document) {
++              return document.select("table.data").select("tr:gt(0)");
 +      }
 +
-        * Extracts the name from the given row.
-        *
-        * @param dataRow
-        *            The row to extract the name from
-        * @return The extracted name
 +      /**
-       private static String extractName(Element dataRow) {
++       * {@inheritDoc}
 +       */
-        * Extracts the size from the given row.
-        *
-        * @param dataRow
-        *            The row to extract the size from
-        * @return The extracted size
++      @Override
++      protected String extractName(Element dataRow) {
 +              return dataRow.select("div.torrentname a.normalgrey").text();
 +      }
 +
 +      /**
-       private static String extractSize(Element dataRow) {
++       * {@inheritDoc}
 +       */
-        * Extracts the magnet URI from the given row.
-        *
-        * @param dataRow
-        *            The row to extract the magnet URI from
-        * @return The extracted magnet URI
++      @Override
++      protected String extractSize(Element dataRow) {
 +              return dataRow.select("td:eq(1)").text();
 +      }
 +
 +      /**
-       private static String extractMagnetUri(Element dataRow) {
++       * {@inheritDoc}
 +       */
-        * Extracts the download URI from the given row.
-        *
-        * @param dataRow
-        *            The row to extract the download URI from
-        * @return The extracted download URI
++      @Override
++      protected String extractMagnetUri(Element dataRow) {
 +              return dataRow.select("a.imagnet").attr("href");
 +      }
 +
 +      /**
-       private static String extractDownloadUri(Element dataRow) {
++       * {@inheritDoc}
 +       */
-        * Extracts the file count from the given row.
-        *
-        * @param dataRow
-        *            The row to extract the file count from
-        * @return The extracted file count
++      @Override
++      protected String extractDownloadUri(Element dataRow) {
 +              return dataRow.select("a.idownload:not(.partner1Button)").attr("href");
 +      }
 +
 +      /**
-       private static int extractFileCount(Element dataRow) {
++       * {@inheritDoc}
 +       */
-        * Extracts the seed count from the given row.
-        *
-        * @param dataRow
-        *            The row to extract the seed count from
-        * @return The extracted seed count
++      @Override
++      protected int extractFileCount(Element dataRow) {
 +              return Integer.valueOf(dataRow.select("td:eq(2)").text());
 +      }
 +
 +      /**
-       private static int extractSeedCount(Element dataRow) {
++       * {@inheritDoc}
 +       */
-        * Extracts the leech count from the given row.
-        *
-        * @param dataRow
-        *            The row to extract the leech count from
-        * @return The extracted leech count
++      @Override
++      protected int extractSeedCount(Element dataRow) {
 +              return Integer.valueOf(dataRow.select("td:eq(4)").text());
 +      }
 +
 +      /**
-       private static int extractLeechCount(Element dataRow) {
++       * {@inheritDoc}
 +       */
++      @Override
++      protected int extractLeechCount(Element dataRow) {
 +              return Integer.valueOf(dataRow.select("td:eq(5)").text());
 +      }
 +
 +}
index 0000000,0000000..45c33c1
new file mode 100644 (file)
--- /dev/null
--- /dev/null
@@@ -1,0 -1,0 +1,98 @@@
++/*
++ * Rhynodge - PirateBayFilter.java - Copyright © 2013 David Roden
++ *
++ * This program is free software: you can redistribute it and/or modify
++ * it under the terms of the GNU General Public License as published by
++ * the Free Software Foundation, either version 3 of the License, or
++ * (at your option) any later version.
++ *
++ * This program is distributed in the hope that it will be useful,
++ * but WITHOUT ANY WARRANTY; without even the implied warranty of
++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
++ * GNU General Public License for more details.
++ *
++ * You should have received a copy of the GNU General Public License
++ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
++ */
++
++package net.pterodactylus.rhynodge.filters;
++
++import java.util.regex.Pattern;
++
++import org.jsoup.nodes.Document;
++import org.jsoup.nodes.Element;
++import org.jsoup.select.Elements;
++
++/**
++ * {@link TorrentSiteFilter} implementation that can parse
++ * {@code thepiratebay.se} result pages.
++ *
++ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
++ */
++public class PirateBayFilter extends TorrentSiteFilter {
++
++      /**
++       * {@inheritDoc}
++       */
++      @Override
++      protected Elements getDataRows(Document document) {
++              return document.select("table#searchResult tbody tr:has(.vertTh)");
++      }
++
++      /**
++       * {@inheritDoc}
++       */
++      @Override
++      protected String extractName(Element dataRow) {
++              return dataRow.select(".detName a").text();
++      }
++
++      /**
++       * {@inheritDoc}
++       */
++      @Override
++      protected String extractSize(Element dataRow) {
++              return dataRow.select(".detDesc").text().split(Pattern.quote(","))[1].trim();
++      }
++
++      /**
++       * {@inheritDoc}
++       */
++      @Override
++      protected String extractMagnetUri(Element dataRow) {
++              return dataRow.select("a[href^=magnet:]").attr("href");
++      }
++
++      /**
++       * {@inheritDoc}
++       */
++      @Override
++      protected String extractDownloadUri(Element dataRow) {
++              return dataRow.select("a[href^=//torrents.]").attr("href");
++      }
++
++      /**
++       * {@inheritDoc}
++       */
++      @Override
++      protected int extractFileCount(Element dataRow) {
++              return 0;
++      }
++
++      /**
++       * {@inheritDoc}
++       */
++      @Override
++      protected int extractSeedCount(Element dataRow) {
++              return Integer.valueOf(dataRow.select("td:eq(2)").text());
++      }
++
++      /**
++       * {@inheritDoc}
++       */
++      @Override
++      protected int extractLeechCount(Element dataRow) {
++              return Integer.valueOf(dataRow.select("td:eq(3)").text());
++      }
++
++}
index 0000000,0000000..b3b5691
new file mode 100644 (file)
--- /dev/null
--- /dev/null
@@@ -1,0 -1,0 +1,167 @@@
++/*
++ * Rhynodge - KickAssTorrentsFilter.java - Copyright © 2013 David Roden
++ *
++ * This program is free software: you can redistribute it and/or modify
++ * it under the terms of the GNU General Public License as published by
++ * the Free Software Foundation, either version 3 of the License, or
++ * (at your option) any later version.
++ *
++ * This program is distributed in the hope that it will be useful,
++ * but WITHOUT ANY WARRANTY; without even the implied warranty of
++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
++ * GNU General Public License for more details.
++ *
++ * You should have received a copy of the GNU General Public License
++ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
++ */
++
++package net.pterodactylus.rhynodge.filters;
++
++import static com.google.common.base.Preconditions.checkState;
++
++import java.io.UnsupportedEncodingException;
++import java.net.URI;
++import java.net.URISyntaxException;
++import java.net.URLEncoder;
++
++import net.pterodactylus.rhynodge.Filter;
++import net.pterodactylus.rhynodge.State;
++import net.pterodactylus.rhynodge.queries.HttpQuery;
++import net.pterodactylus.rhynodge.states.FailedState;
++import net.pterodactylus.rhynodge.states.HtmlState;
++import net.pterodactylus.rhynodge.states.TorrentState;
++import net.pterodactylus.rhynodge.states.TorrentState.TorrentFile;
++
++import org.jsoup.nodes.Document;
++import org.jsoup.nodes.Element;
++import org.jsoup.select.Elements;
++
++/**
++ * {@link Filter} implementation that parses a {@link TorrentState} from an
++ * {@link HtmlState} which was generated by a {@link HttpQuery} to
++ * {@code kickasstorrents.ph}.
++ *
++ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
++ */
++public abstract class TorrentSiteFilter implements Filter {
++
++      /**
++       * {@inheritDoc}
++       */
++      @Override
++      public State filter(State state) {
++              if (!state.success()) {
++                      return FailedState.from(state);
++              }
++              checkState(state instanceof HtmlState, "state is not an HtmlState but a %s", state.getClass().getName());
++
++              /* get result table. */
++              Document document = ((HtmlState) state).document();
++
++              /* iterate over all rows. */
++              Elements dataRows = getDataRows(document);
++              TorrentState torrentState = new TorrentState();
++              for (Element dataRow : dataRows) {
++                      String name = extractName(dataRow);
++                      String size = extractSize(dataRow);
++                      String magnetUri = extractMagnetUri(dataRow);
++                      String downloadUri = extractDownloadUri(dataRow);
++                      int fileCount = extractFileCount(dataRow);
++                      int seedCount = extractSeedCount(dataRow);
++                      int leechCount = extractLeechCount(dataRow);
++                      try {
++                              if ((downloadUri != null) && (downloadUri.length() > 0)) {
++                                      downloadUri = new URI(((HtmlState) state).uri()).resolve(URLEncoder.encode(downloadUri, "UTF-8").replace("%2F", "/")).toString();
++                              } else {
++                                      downloadUri = null;
++                              }
++                              TorrentFile torrentFile = new TorrentFile(name, size, magnetUri, downloadUri, fileCount, seedCount, leechCount);
++                              torrentState.addTorrentFile(torrentFile);
++                      } catch (URISyntaxException use1) {
++                              /* ignore; if uri was wrong, we wouldn’t be here. */
++                      } catch (UnsupportedEncodingException uee1) {
++                              /* ignore, all JVMs can do UTF-8. */
++                      }
++              }
++
++              return torrentState;
++      }
++
++      //
++      // ABSTRACT METHODS
++      //
++
++      /**
++       * Returns the data rows from the given document.
++       *
++       * @param document
++       *            The document to get the data rows from
++       * @return The data rows
++       */
++      protected abstract Elements getDataRows(Document document);
++
++      /**
++       * Extracts the name from the given row.
++       *
++       * @param dataRow
++       *            The row to extract the name from
++       * @return The extracted name
++       */
++      protected abstract String extractName(Element dataRow);
++
++      /**
++       * Extracts the size from the given row.
++       *
++       * @param dataRow
++       *            The row to extract the size from
++       * @return The extracted size
++       */
++      protected abstract String extractSize(Element dataRow);
++
++      /**
++       * Extracts the magnet URI from the given row.
++       *
++       * @param dataRow
++       *            The row to extract the magnet URI from
++       * @return The extracted magnet URI
++       */
++      protected abstract String extractMagnetUri(Element dataRow);
++
++      /**
++       * Extracts the download URI from the given row.
++       *
++       * @param dataRow
++       *            The row to extract the download URI from
++       * @return The extracted download URI
++       */
++      protected abstract String extractDownloadUri(Element dataRow);
++
++      /**
++       * Extracts the file count from the given row.
++       *
++       * @param dataRow
++       *            The row to extract the file count from
++       * @return The extracted file count, or {@code 0} if the file count can not
++       *         be extracted
++       */
++      protected abstract int extractFileCount(Element dataRow);
++
++      /**
++       * Extracts the seed count from the given row.
++       *
++       * @param dataRow
++       *            The row to extract the seed count from
++       * @return The extracted seed count
++       */
++      protected abstract int extractSeedCount(Element dataRow);
++
++      /**
++       * Extracts the leech count from the given row.
++       *
++       * @param dataRow
++       *            The row to extract the leech count from
++       * @return The extracted leech count
++       */
++      protected abstract int extractLeechCount(Element dataRow);
++
++}
index ce5b06f,0000000..c4c3e0b
mode 100644,000000..100644
--- /dev/null
@@@ -1,305 -1,0 +1,307 @@@
-                * @return The magnet URI of the file
 +/*
 + * Rhynodge - TorrentState.java - Copyright © 2013 David Roden
 + *
 + * This program is free software: you can redistribute it and/or modify
 + * it under the terms of the GNU General Public License as published by
 + * the Free Software Foundation, either version 3 of the License, or
 + * (at your option) any later version.
 + *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + *
 + * You should have received a copy of the GNU General Public License
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.rhynodge.states;
 +
 +import java.nio.charset.Charset;
 +import java.util.Iterator;
 +import java.util.List;
 +
 +import net.pterodactylus.rhynodge.State;
 +import net.pterodactylus.rhynodge.states.TorrentState.TorrentFile;
 +
 +import org.apache.http.NameValuePair;
 +import org.apache.http.client.utils.URLEncodedUtils;
 +
 +import com.fasterxml.jackson.annotation.JsonProperty;
 +import com.google.common.collect.Lists;
 +
 +/**
 + * {@link State} that contains information about an arbitrary number of torrent
 + * files.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class TorrentState extends AbstractState implements Iterable<TorrentFile> {
 +
 +      /** The torrent files. */
 +      @JsonProperty
 +      private List<TorrentFile> files = Lists.newArrayList();
 +
 +      //
 +      // ACCESSORS
 +      //
 +
 +      /**
 +       * Adds a torrent file to this state.
 +       *
 +       * @param torrentFile
 +       *            The torrent file to add
 +       * @return This state
 +       */
 +      public TorrentState addTorrentFile(TorrentFile torrentFile) {
 +              files.add(torrentFile);
 +              return this;
 +      }
 +
 +      //
 +      // ITERABLE METHODS
 +      //
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public Iterator<TorrentFile> iterator() {
 +              return files.iterator();
 +      }
 +
 +      //
 +      // OBJECT METHODS
 +      //
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public String toString() {
 +              return String.format("%s[files=%s]", getClass().getSimpleName(), files);
 +      }
 +
 +      /**
 +       * Container for torrent file data.
 +       *
 +       * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 +       */
 +      public static class TorrentFile {
 +
 +              /** The name of the file. */
 +              @JsonProperty
 +              private final String name;
 +
 +              /** The size of the file. */
 +              @JsonProperty
 +              private final String size;
 +
 +              /** The magnet URI of the file. */
 +              @JsonProperty
 +              private final String magnetUri;
 +
 +              /** The download URI of the file. */
 +              @JsonProperty
 +              private final String downloadUri;
 +
 +              /** The number of files in this torrent. */
 +              @JsonProperty
 +              private final int fileCount;
 +
 +              /** The number of seeds connected to this torrent. */
 +              @JsonProperty
 +              private final int seedCount;
 +
 +              /** The number of leechers connected to this torrent. */
 +              @JsonProperty
 +              private final int leechCount;
 +
 +              /**
 +               * No-arg constructor for deserialization.
 +               */
 +              @SuppressWarnings("unused")
 +              private TorrentFile() {
 +                      this(null, null, null, null, 0, 0, 0);
 +              }
 +
 +              /**
 +               * Creates a new torrent file.
 +               *
 +               * @param name
 +               *            The name of the file
 +               * @param size
 +               *            The size of the file
 +               * @param magnetUri
 +               *            The magnet URI of the file
 +               * @param downloadUri
 +               *            The download URI of the file
 +               * @param fileCount
 +               *            The number of files
 +               * @param seedCount
 +               *            The number of connected seeds
 +               * @param leechCount
 +               *            The number of connected leechers
 +               */
 +              public TorrentFile(String name, String size, String magnetUri, String downloadUri, int fileCount, int seedCount, int leechCount) {
 +                      this.name = name;
 +                      this.size = size;
 +                      this.magnetUri = magnetUri;
 +                      this.downloadUri = downloadUri;
 +                      this.fileCount = fileCount;
 +                      this.seedCount = seedCount;
 +                      this.leechCount = leechCount;
 +              }
 +
 +              //
 +              // ACCESSORS
 +              //
 +
 +              /**
 +               * Returns the name of the file.
 +               *
 +               * @return The name of the file
 +               */
 +              public String name() {
 +                      return name;
 +              }
 +
 +              /**
 +               * Returns the size of the file. The returned size may included
 +               * non-numeric information, such as units (e. g. “860.46 MB”).
 +               *
 +               * @return The size of the file
 +               */
 +              public String size() {
 +                      return size;
 +              }
 +
 +              /**
 +               * Returns the magnet URI of the file.
 +               *
-                * @return The download URI of the file
++               * @return The magnet URI of the file, or {@code null} if there is no
++               *         magnet URI for this torrent file
 +               */
 +              public String magnetUri() {
 +                      return magnetUri;
 +              }
 +
 +              /**
 +               * Returns the download URI of the file.
 +               *
++               * @return The download URI of the file, or {@code null} if there is no
++               *         download URI for this torrent file
 +               */
 +              public String downloadUri() {
 +                      return downloadUri;
 +              }
 +
 +              /**
 +               * Returns the number of files in this torrent.
 +               *
 +               * @return The number of files in this torrent
 +               */
 +              public int fileCount() {
 +                      return fileCount;
 +              }
 +
 +              /**
 +               * Returns the number of seeds connected to this torrent.
 +               *
 +               * @return The number of connected seeds
 +               */
 +              public int seedCount() {
 +                      return seedCount;
 +              }
 +
 +              /**
 +               * Returns the number of leechers connected to this torrent.
 +               *
 +               * @return The number of connected leechers
 +               */
 +              public int leechCount() {
 +                      return leechCount;
 +              }
 +
 +              //
 +              // PRIVATE METHODS
 +              //
 +
 +              /**
 +               * Generates an ID for this file. If a {@link #magnetUri} is set, an ID
 +               * is {@link #extractId(String) extracted} from it. Otherwise the magnet
 +               * URI is used. If the {@link #magnetUri} is not set, the
 +               * {@link #downloadUri} is used. If that is not set either, the name of
 +               * the file is returned.
 +               *
 +               * @return The generated ID
 +               */
 +              private String generateId() {
 +                      if (magnetUri != null) {
 +                              String id = extractId(magnetUri);
 +                              if (id != null) {
 +                                      return id;
 +                              }
 +                              return magnetUri;
 +                      }
 +                      return (downloadUri != null) ? downloadUri : name;
 +              }
 +
 +              //
 +              // STATIC METHODS
 +              //
 +
 +              /**
 +               * Tries to extract the “exact target” of a magnet URI.
 +               *
 +               * @param magnetUri
 +               *            The magnet URI to extract the “xt” from
 +               * @return The extracted ID, or {@code null} if no ID could be found
 +               */
 +              private static String extractId(String magnetUri) {
 +                      List<NameValuePair> parameters = URLEncodedUtils.parse(magnetUri.substring("magnet:?".length()), Charset.forName("UTF-8"));
 +                      for (NameValuePair parameter : parameters) {
 +                              if (parameter.getName().equals("xt")) {
 +                                      return parameter.getValue();
 +                              }
 +                      }
 +                      return null;
 +              }
 +
 +              //
 +              // OBJECT METHODS
 +              //
 +
 +              /**
 +               * {@inheritDoc}
 +               */
 +              @Override
 +              public int hashCode() {
 +                      return (generateId() != null) ? generateId().hashCode() : 0;
 +              }
 +
 +              /**
 +               * {@inheritDoc}
 +               */
 +              @Override
 +              public boolean equals(Object object) {
 +                      if (!(object instanceof TorrentFile)) {
 +                              return false;
 +                      }
 +                      if (generateId() != null) {
 +                              return generateId().equals(((TorrentFile) object).generateId());
 +                      }
 +                      return false;
 +              }
 +
 +              /**
 +               * {@inheritDoc}
 +               */
 +              @Override
 +              public String toString() {
 +                      return String.format("%s(%s,%s,%s)", name(), size(), magnetUri(), downloadUri());
 +              }
 +
 +      }
 +
 +}
index f23869d,0000000..a8e321c
mode 100644,000000..100644
--- /dev/null
@@@ -1,233 -1,0 +1,253 @@@
-                                       stringBuilder.append("    Magnet: ").append(torrentFile.magnetUri()).append("\n");
-                                       stringBuilder.append("    Download: ").append(torrentFile.downloadUri()).append("\n");
 +/*
 + * Rhynodge - NewEpisodeTrigger.java - Copyright © 2013 David Roden
 + *
 + * This program is free software: you can redistribute it and/or modify
 + * it under the terms of the GNU General Public License as published by
 + * the Free Software Foundation, either version 3 of the License, or
 + * (at your option) any later version.
 + *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + *
 + * You should have received a copy of the GNU General Public License
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.rhynodge.triggers;
 +
 +import static com.google.common.base.Preconditions.checkState;
 +
 +import java.util.Collection;
 +
 +import net.pterodactylus.rhynodge.Reaction;
 +import net.pterodactylus.rhynodge.State;
 +import net.pterodactylus.rhynodge.Trigger;
 +import net.pterodactylus.rhynodge.output.DefaultOutput;
 +import net.pterodactylus.rhynodge.output.Output;
 +import net.pterodactylus.rhynodge.states.EpisodeState;
 +import net.pterodactylus.rhynodge.states.EpisodeState.Episode;
 +import net.pterodactylus.rhynodge.states.TorrentState.TorrentFile;
 +
 +import org.apache.commons.lang3.StringEscapeUtils;
 +
 +import com.google.common.base.Predicate;
 +import com.google.common.collect.Collections2;
 +
 +/**
 + * {@link Trigger} implementation that compares two {@link EpisodeState}s for
 + * new and changed {@link Episode}s.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class NewEpisodeTrigger implements Trigger {
 +
 +      /** All new episodes. */
 +      private Collection<Episode> newEpisodes;
 +
 +      /** All changed episodes. */
 +      private Collection<Episode> changedEpisodes;
 +
 +      //
 +      // TRIGGER METHODS
 +      //
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public boolean triggers(State currentState, State previousState) {
 +              checkState(currentState instanceof EpisodeState, "currentState is not a EpisodeState but a %s", currentState.getClass().getName());
 +              checkState(previousState instanceof EpisodeState, "previousState is not a EpisodeState but a %s", currentState.getClass().getName());
 +              final EpisodeState currentEpisodeState = (EpisodeState) currentState;
 +              final EpisodeState previousEpisodeState = (EpisodeState) previousState;
 +
 +              newEpisodes = Collections2.filter(currentEpisodeState.episodes(), new Predicate<Episode>() {
 +
 +                      @Override
 +                      public boolean apply(Episode episode) {
 +                              return !previousEpisodeState.episodes().contains(episode);
 +                      }
 +              });
 +
 +              changedEpisodes = Collections2.filter(currentEpisodeState.episodes(), new Predicate<Episode>() {
 +
 +                      @Override
 +                      public boolean apply(Episode episode) {
 +                              if (!previousEpisodeState.episodes().contains(episode)) {
 +                                      return false;
 +                              }
 +
 +                              /* find previous episode. */
 +                              final Episode previousEpisode = findPreviousEpisode(episode);
 +
 +                              /* compare the list of torrent files. */
 +                              Collection<TorrentFile> newTorrentFiles = Collections2.filter(episode.torrentFiles(), new Predicate<TorrentFile>() {
 +
 +                                      @Override
 +                                      public boolean apply(TorrentFile torrentFile) {
 +                                              return !previousEpisode.torrentFiles().contains(torrentFile);
 +                                      }
 +                              });
 +
 +                              return !newTorrentFiles.isEmpty();
 +                      }
 +
 +                      private Episode findPreviousEpisode(Episode episode) {
 +                              for (Episode previousStateEpisode : previousEpisodeState) {
 +                                      if (previousStateEpisode.equals(episode)) {
 +                                              return previousStateEpisode;
 +                                      }
 +                              }
 +                              return null;
 +                      }
 +
 +              });
 +
 +              return !newEpisodes.isEmpty() || !changedEpisodes.isEmpty();
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public Output output(Reaction reaction) {
 +              String summary;
 +              if (!newEpisodes.isEmpty()) {
 +                      if (!changedEpisodes.isEmpty()) {
 +                              summary = String.format("%d new and %d changed Torrent(s) for “%s!”", newEpisodes.size(), changedEpisodes.size(), reaction.name());
 +                      } else {
 +                              summary = String.format("%d new Torrent(s) for “%s!”", newEpisodes.size(), reaction.name());
 +                      }
 +              } else {
 +                      summary = String.format("%d changed Torrent(s) for “%s!”", changedEpisodes.size(), reaction.name());
 +              }
 +              DefaultOutput output = new DefaultOutput(summary);
 +              output.addText("text/plain", generatePlainText(reaction, newEpisodes, changedEpisodes));
 +              output.addText("text/html", generateHtmlText(reaction, newEpisodes, changedEpisodes));
 +              return output;
 +      }
 +
 +      //
 +      // STATIC METHODS
 +      //
 +
 +      /**
 +       * Generates the plain text trigger output.
 +       *
 +       * @param reaction
 +       *            The reaction that was triggered
 +       * @param newEpisodes
 +       *            The new episodes
 +       * @param changedEpisodes
 +       *            The changed episodes
 +       * @return The plain text output
 +       */
 +      private static String generatePlainText(Reaction reaction, Collection<Episode> newEpisodes, Collection<Episode> changedEpisodes) {
 +              StringBuilder stringBuilder = new StringBuilder();
 +              if (!newEpisodes.isEmpty()) {
 +                      stringBuilder.append(reaction.name()).append(" - New Episodes\n\n");
 +                      for (Episode episode : newEpisodes) {
 +                              stringBuilder.append("- ").append(episode.identifier()).append("\n");
 +                              for (TorrentFile torrentFile : episode) {
 +                                      stringBuilder.append("  - ").append(torrentFile.name()).append(", ").append(torrentFile.size()).append("\n");
-                                       stringBuilder.append("    Magnet: ").append(torrentFile.magnetUri()).append("\n");
-                                       stringBuilder.append("    Download: ").append(torrentFile.downloadUri()).append("\n");
++                                      if ((torrentFile.magnetUri() != null) && (torrentFile.magnetUri().length() > 0)) {
++                                              stringBuilder.append("    Magnet: ").append(torrentFile.magnetUri()).append("\n");
++                                      }
++                                      if ((torrentFile.downloadUri() != null) && (torrentFile.downloadUri().length() > 0)) {
++                                              stringBuilder.append("    Download: ").append(torrentFile.downloadUri()).append("\n");
++                                      }
 +                              }
 +                      }
 +              }
 +              if (!changedEpisodes.isEmpty()) {
 +                      stringBuilder.append(reaction.name()).append(" - Changed Episodes\n\n");
 +                      for (Episode episode : changedEpisodes) {
 +                              stringBuilder.append("- ").append(episode.identifier()).append("\n");
 +                              for (TorrentFile torrentFile : episode) {
 +                                      stringBuilder.append("  - ").append(torrentFile.name()).append(", ").append(torrentFile.size()).append("\n");
-                                       htmlBuilder.append("<div><a href=\"").append(StringEscapeUtils.escapeHtml4(torrentFile.magnetUri())).append("\">Magnet</a> ");
-                                       htmlBuilder.append("<a href=\"").append(StringEscapeUtils.escapeHtml4(torrentFile.downloadUri())).append("\">Download</a></div>\n");
++                                      if ((torrentFile.magnetUri() != null) && (torrentFile.magnetUri().length() > 0)) {
++                                              stringBuilder.append("    Magnet: ").append(torrentFile.magnetUri()).append("\n");
++                                      }
++                                      if ((torrentFile.downloadUri() != null) && (torrentFile.downloadUri().length() > 0)) {
++                                              stringBuilder.append("    Download: ").append(torrentFile.downloadUri()).append("\n");
++                                      }
 +                              }
 +                      }
 +              }
 +              return stringBuilder.toString();
 +      }
 +
 +      /**
 +       * Generates the HTML trigger output.
 +       *
 +       * @param reaction
 +       *            The reaction that was triggered
 +       * @param newEpisodes
 +       *            The new episodes
 +       * @param changedEpisodes
 +       *            The changed episodes
 +       * @return The HTML output
 +       */
 +      private static String generateHtmlText(Reaction reaction, Collection<Episode> newEpisodes, Collection<Episode> changedEpisodes) {
 +              StringBuilder htmlBuilder = new StringBuilder();
 +              htmlBuilder.append("<html><body>\n");
 +              htmlBuilder.append("<h1>").append(StringEscapeUtils.escapeHtml4(reaction.name())).append("</h1>\n");
 +              if (!newEpisodes.isEmpty()) {
 +                      htmlBuilder.append("<h2>New Episodes</h2>\n");
 +                      htmlBuilder.append("<ul>\n");
 +                      for (Episode episode : newEpisodes) {
 +                              htmlBuilder.append("<li>Season ").append(episode.season()).append(", Episode ").append(episode.episode()).append("</li>\n");
 +                              htmlBuilder.append("<ul>\n");
 +                              for (TorrentFile torrentFile : episode) {
 +                                      htmlBuilder.append("<li>").append(StringEscapeUtils.escapeHtml4(torrentFile.name())).append("</li>\n");
 +                                      htmlBuilder.append("<div>");
 +                                      htmlBuilder.append("<strong>").append(StringEscapeUtils.escapeHtml4(torrentFile.size())).append("</strong>, ");
 +                                      htmlBuilder.append("<strong>").append(torrentFile.fileCount()).append("</strong> file(s), ");
 +                                      htmlBuilder.append("<strong>").append(torrentFile.seedCount()).append("</strong> seed(s), ");
 +                                      htmlBuilder.append("<strong>").append(torrentFile.leechCount()).append("</strong> leecher(s)</div>\n");
-                                       htmlBuilder.append("<div><a href=\"").append(StringEscapeUtils.escapeHtml4(torrentFile.magnetUri())).append("\">Magnet</a> ");
-                                       htmlBuilder.append("<a href=\"").append(StringEscapeUtils.escapeHtml4(torrentFile.downloadUri())).append("\">Download</a></div>\n");
++                                      htmlBuilder.append("<div>");
++                                      if ((torrentFile.magnetUri() != null) && (torrentFile.magnetUri().length() > 0)) {
++                                              htmlBuilder.append("<a href=\"").append(StringEscapeUtils.escapeHtml4(torrentFile.magnetUri())).append("\">Magnet</a> ");
++                                      }
++                                      if ((torrentFile.downloadUri() != null) && (torrentFile.downloadUri().length() > 0)) {
++                                              htmlBuilder.append("<a href=\"").append(StringEscapeUtils.escapeHtml4(torrentFile.downloadUri())).append("\">Download</a>");
++                                      }
++                                      htmlBuilder.append("</div>\n");
 +                              }
 +                              htmlBuilder.append("</ul>\n");
 +                      }
 +                      htmlBuilder.append("</ul>\n");
 +              }
 +              if (!changedEpisodes.isEmpty()) {
 +                      htmlBuilder.append("<h2>Changed Episodes</h2>\n");
 +                      htmlBuilder.append("<ul>\n");
 +                      for (Episode episode : changedEpisodes) {
 +                              htmlBuilder.append("<li>Season ").append(episode.season()).append(", Episode ").append(episode.episode()).append("</li>\n");
 +                              htmlBuilder.append("<ul>\n");
 +                              for (TorrentFile torrentFile : episode) {
 +                                      htmlBuilder.append("<li>").append(StringEscapeUtils.escapeHtml4(torrentFile.name())).append("</li>\n");
 +                                      htmlBuilder.append("<div>");
 +                                      htmlBuilder.append("<strong>").append(StringEscapeUtils.escapeHtml4(torrentFile.size())).append("</strong>, ");
 +                                      htmlBuilder.append("<strong>").append(torrentFile.fileCount()).append("</strong> file(s), ");
 +                                      htmlBuilder.append("<strong>").append(torrentFile.seedCount()).append("</strong> seed(s), ");
 +                                      htmlBuilder.append("<strong>").append(torrentFile.leechCount()).append("</strong> leecher(s)</div>\n");
++                                      htmlBuilder.append("<div>");
++                                      if ((torrentFile.magnetUri() != null) && (torrentFile.magnetUri().length() > 0)) {
++                                              htmlBuilder.append("<a href=\"").append(StringEscapeUtils.escapeHtml4(torrentFile.magnetUri())).append("\">Magnet</a> ");
++                                      }
++                                      if ((torrentFile.downloadUri() != null) && (torrentFile.downloadUri().length() > 0)) {
++                                              htmlBuilder.append("<a href=\"").append(StringEscapeUtils.escapeHtml4(torrentFile.downloadUri())).append("\">Download</a>");
++                                      }
++                                      htmlBuilder.append("</div>\n");
 +                              }
 +                              htmlBuilder.append("</ul>\n");
 +                      }
 +                      htmlBuilder.append("</ul>\n");
 +              }
 +              htmlBuilder.append("</body></html>\n");
 +              return htmlBuilder.toString();
 +      }
 +
 +}
index a21a8d6,0000000..2803bb5
mode 100644,000000..100644
--- /dev/null
@@@ -1,130 -1,0 +1,138 @@@
-                       plainText.append('\t').append(torrentFile.magnetUri()).append('\n');
-                       plainText.append('\t').append(torrentFile.downloadUri()).append('\n');
 +/*
 + * Rhynodge - NewTorrentTrigger.java - Copyright © 2013 David Roden
 + *
 + * This program is free software: you can redistribute it and/or modify
 + * it under the terms of the GNU General Public License as published by
 + * the Free Software Foundation, either version 3 of the License, or
 + * (at your option) any later version.
 + *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + *
 + * You should have received a copy of the GNU General Public License
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.rhynodge.triggers;
 +
 +import static com.google.common.base.Preconditions.checkState;
 +
 +import java.util.List;
 +
 +import net.pterodactylus.rhynodge.Reaction;
 +import net.pterodactylus.rhynodge.State;
 +import net.pterodactylus.rhynodge.Trigger;
 +import net.pterodactylus.rhynodge.output.DefaultOutput;
 +import net.pterodactylus.rhynodge.output.Output;
 +import net.pterodactylus.rhynodge.states.TorrentState;
 +import net.pterodactylus.rhynodge.states.TorrentState.TorrentFile;
 +
 +import org.apache.commons.lang3.StringEscapeUtils;
 +
 +import com.google.common.collect.Lists;
 +
 +/**
 + * {@link Trigger} implementation that is triggered by {@link TorrentFile}s that
 + * appear in the current {@link TorrentState} but not in the previous one.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class NewTorrentTrigger implements Trigger {
 +
 +      /** The newly detected torrent files. */
 +      private List<TorrentFile> torrentFiles = Lists.newArrayList();
 +
 +      //
 +      // TRIGGER METHODS
 +      //
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public boolean triggers(State currentState, State previousState) {
 +              checkState(currentState instanceof TorrentState, "currentState is not a TorrentState but a %s", currentState.getClass().getName());
 +              checkState(previousState instanceof TorrentState, "previousState is not a TorrentState but a %s", currentState.getClass().getName());
 +              TorrentState currentTorrentState = (TorrentState) currentState;
 +              TorrentState previousTorrentState = (TorrentState) previousState;
 +              torrentFiles.clear();
 +              for (TorrentFile torrentFile : currentTorrentState) {
 +                      torrentFiles.add(torrentFile);
 +              }
 +              for (TorrentFile torrentFile : previousTorrentState) {
 +                      torrentFiles.remove(torrentFile);
 +              }
 +              return !torrentFiles.isEmpty();
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public Output output(Reaction reaction) {
 +              DefaultOutput output = new DefaultOutput(String.format("Found %d new Torrent(s) for “%s!”", torrentFiles.size(), reaction.name()));
 +              output.addText("text/plain", getPlainTextList(torrentFiles));
 +              output.addText("text/html", getHtmlTextList(torrentFiles));
 +              return output;
 +      }
 +
 +      //
 +      // STATIC METHODS
 +      //
 +
 +      /**
 +       * Generates a plain text list of torrent files.
 +       *
 +       * @param torrentFiles
 +       *            The torrent files to list
 +       * @return The generated plain text
 +       */
 +      private static String getPlainTextList(List<TorrentFile> torrentFiles) {
 +              StringBuilder plainText = new StringBuilder();
 +              plainText.append("New Torrents:\n\n");
 +              for (TorrentFile torrentFile : torrentFiles) {
 +                      plainText.append(torrentFile.name()).append('\n');
 +                      plainText.append('\t').append(torrentFile.size()).append(" in ").append(torrentFile.fileCount()).append(" file(s)\n");
 +                      plainText.append('\t').append(torrentFile.seedCount()).append(" seed(s), ").append(torrentFile.leechCount()).append(" leecher(s)\n");
-                       htmlText.append(String.format("<div><a href=\"%s\">Magnet URI</a></div>", StringEscapeUtils.escapeHtml4(torrentFile.magnetUri())));
-                       htmlText.append(String.format("<div><a href=\"%s\">Download URI</a></div>", StringEscapeUtils.escapeHtml4(torrentFile.downloadUri())));
++                      if ((torrentFile.magnetUri() != null) && (torrentFile.magnetUri().length() > 0)) {
++                              plainText.append('\t').append(torrentFile.magnetUri()).append('\n');
++                      }
++                      if ((torrentFile.downloadUri() != null) && (torrentFile.downloadUri().length() > 0)) {
++                              plainText.append('\t').append(torrentFile.downloadUri()).append('\n');
++                      }
 +                      plainText.append('\n');
 +              }
 +              return plainText.toString();
 +      }
 +
 +      /**
 +       * Generates an HTML list of the given torrent files.
 +       *
 +       * @param torrentFiles
 +       *            The torrent files to list
 +       * @return The generated HTML
 +       */
 +      private static String getHtmlTextList(List<TorrentFile> torrentFiles) {
 +              StringBuilder htmlText = new StringBuilder();
 +              htmlText.append("<html><body>\n");
 +              htmlText.append("<h1>New Torrents</h1>\n");
 +              htmlText.append("<ul>\n");
 +              for (TorrentFile torrentFile : torrentFiles) {
 +                      htmlText.append("<li><strong>").append(StringEscapeUtils.escapeHtml4(torrentFile.name())).append("</strong></li>");
 +                      htmlText.append("<div>Size: <strong>").append(StringEscapeUtils.escapeHtml4(torrentFile.size())).append("</strong> in <strong>").append(torrentFile.fileCount()).append("</strong> file(s)</div>");
 +                      htmlText.append("<div><strong>").append(torrentFile.seedCount()).append("</strong> seed(s), <strong>").append(torrentFile.leechCount()).append("</strong> leecher(s)</div>");
++                      if ((torrentFile.magnetUri() != null) && (torrentFile.magnetUri().length() > 0)) {
++                              htmlText.append(String.format("<div><a href=\"%s\">Magnet URI</a></div>", StringEscapeUtils.escapeHtml4(torrentFile.magnetUri())));
++                      }
++                      if ((torrentFile.downloadUri() != null) && (torrentFile.downloadUri().length() > 0)) {
++                              htmlText.append(String.format("<div><a href=\"%s\">Download URI</a></div>", StringEscapeUtils.escapeHtml4(torrentFile.downloadUri())));
++                      }
 +              }
 +              htmlText.append("</ul>\n");
 +              htmlText.append("</body></html>\n");
 +              return htmlText.toString();
 +      }
 +
 +}
index 0000000,f2eed53..ad79213
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,50 +1,50 @@@
 -                                      "value": "reactor@reactor.de"
+ {
+       "enabled": false,
+       "name": "Example Reaction",
+       "query":
+               {
+                       "class": "HttpQuery",
+                       "parameters": [
+                               {
+                                       "name": "url",
+                                       "value": "http://thepiratebay.se/search/Example%20Words/0/3/0"
+                               }
+                       ]
+               },
+       "filters": [
+               {
+                       "class": "HtmlFilter"
+               },
+               {
+                       "class": "PirateBayFilter"
+               }
+       ],
+       "trigger":
+               {
+                       "class": "NewTorrentTrigger"
+               },
+       "action":
+               {
+                       "class": "EmailAction",
+                       "parameters": [
+                               {
+                                       "name": "smtpHostname",
+                                       "value": "smtp"
+                               },
+                               {
+                                       "name": "sender",
++                                      "value": "rhynodge@rhynodge.net"
+                               },
+                               {
+                                       "name": "recipient",
+                                       "value": "recipient@recipient.de"
+                               }
+                       ]
+               },
+       "updateInterval": 3600
+ }