From 7e26f1544aef6721b74c7f479b8c103de4deef8b Mon Sep 17 00:00:00 2001 From: =?utf8?q?David=20=E2=80=98Bombe=E2=80=99=20Roden?= Date: Mon, 7 Jan 2013 19:15:02 +0100 Subject: [PATCH] Add filter that extract episode informations from torrent files. --- .../reactor/filters/EpisodeFilter.java | 102 +++++++++ .../pterodactylus/reactor/states/EpisodeState.java | 236 +++++++++++++++++++++ .../reactor/triggers/NewEpisodeTrigger.java | 233 ++++++++++++++++++++ 3 files changed, 571 insertions(+) create mode 100644 src/main/java/net/pterodactylus/reactor/filters/EpisodeFilter.java create mode 100644 src/main/java/net/pterodactylus/reactor/states/EpisodeState.java create mode 100644 src/main/java/net/pterodactylus/reactor/triggers/NewEpisodeTrigger.java diff --git a/src/main/java/net/pterodactylus/reactor/filters/EpisodeFilter.java b/src/main/java/net/pterodactylus/reactor/filters/EpisodeFilter.java new file mode 100644 index 0000000..3f3b6a7 --- /dev/null +++ b/src/main/java/net/pterodactylus/reactor/filters/EpisodeFilter.java @@ -0,0 +1,102 @@ +/* + * Reactor - EpisodeFilter.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 . + */ + +package net.pterodactylus.reactor.filters; + +import static com.google.common.base.Preconditions.checkState; + +import java.util.LinkedHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import net.pterodactylus.reactor.Filter; +import net.pterodactylus.reactor.State; +import net.pterodactylus.reactor.states.EpisodeState; +import net.pterodactylus.reactor.states.EpisodeState.Episode; +import net.pterodactylus.reactor.states.FailedState; +import net.pterodactylus.reactor.states.TorrentState; +import net.pterodactylus.reactor.states.TorrentState.TorrentFile; + +/** + * {@link Filter} implementation that extracts {@link Episode} information from + * the {@link TorrentFile}s contained in a {@link TorrentState}. + * + * @author David ‘Bombe’ Roden + */ +public class EpisodeFilter implements Filter { + + /** The pattern to parse episode information from the filename. */ + private static Pattern episodePattern = Pattern.compile("S(\\d{2})E(\\d{2})|[^\\d](\\d{1,2})x(\\d{2})[^\\d]"); + + // + // FILTER METHODS + // + + /** + * {@inheritDoc} + */ + @Override + public State filter(State state) { + if (!state.success()) { + return FailedState.from(state); + } + checkState(state instanceof TorrentState, "state is not a TorrentState but a %s!", state.getClass()); + + TorrentState torrentState = (TorrentState) state; + LinkedHashMap episodes = new LinkedHashMap(); + for (TorrentFile torrentFile : torrentState) { + Episode episode = extractEpisode(torrentFile); + if (episode == null) { + continue; + } + episodes.put(episode, episode); + episode = episodes.get(episode); + episode.addTorrentFile(torrentFile); + } + + return new EpisodeState(episodes.values()); + } + + // + // STATIC METHODS + // + + /** + * Extracts episode information from the given torrent file. + * + * @param torrentFile + * The torrent file to extract the episode information from + * @return The extracted episode information, or {@code null} if no episode + * information could be found + */ + private static Episode extractEpisode(TorrentFile torrentFile) { + Matcher matcher = episodePattern.matcher(torrentFile.name()); + if (!matcher.find()) { + return null; + } + String seasonString = matcher.group(1); + String episodeString = matcher.group(2); + if ((seasonString == null) && (episodeString == null)) { + seasonString = matcher.group(3); + episodeString = matcher.group(4); + } + int season = Integer.valueOf(seasonString); + int episode = Integer.valueOf(episodeString); + return new Episode(season, episode); + } + +} diff --git a/src/main/java/net/pterodactylus/reactor/states/EpisodeState.java b/src/main/java/net/pterodactylus/reactor/states/EpisodeState.java new file mode 100644 index 0000000..1c120d6 --- /dev/null +++ b/src/main/java/net/pterodactylus/reactor/states/EpisodeState.java @@ -0,0 +1,236 @@ +/* + * Reactor - EpisodeState.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 . + */ + +package net.pterodactylus.reactor.states; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +import net.pterodactylus.reactor.State; +import net.pterodactylus.reactor.filters.EpisodeFilter; +import net.pterodactylus.reactor.states.EpisodeState.Episode; +import net.pterodactylus.reactor.states.TorrentState.TorrentFile; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * {@link State} implementation that stores episodes of TV shows, parsed via + * {@link EpisodeFilter} from a previous {@link TorrentState}. + * + * @author David ‘Bombe’ Roden + */ +public class EpisodeState extends AbstractState implements Iterable { + + /** The episodes found in the current request. */ + @JsonProperty + private final List episodes = new ArrayList(); + + /** + * No-arg constructor for deserialization. + */ + @SuppressWarnings("unused") + private EpisodeState() { + this(Collections. emptySet()); + } + + /** + * Creates a new episode state. + * + * @param episodes + * The episodes of the request + */ + public EpisodeState(Collection episodes) { + this.episodes.addAll(episodes); + } + + // + // ACCESSORS + // + + /** + * Returns all episodes contained in this state. + * + * @return The episodes of this state + */ + public Collection episodes() { + return Collections.unmodifiableCollection(episodes); + } + + // + // ITERABLE INTERFACE + // + + /** + * {@inheritDoc} + */ + @Override + public Iterator iterator() { + return episodes.iterator(); + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() { + return String.format("%s[episodes=%s]", getClass().getSimpleName(), episodes); + } + + /** + * Stores attributes for an episode. + * + * @author David ‘Bombe’ Roden + */ + public static class Episode implements Iterable { + + /** The season of the episode. */ + @JsonProperty + private final int season; + + /** The number of the episode. */ + @JsonProperty + private final int episode; + + /** The torrent files for this episode. */ + @JsonProperty + private final List torrentFiles = new ArrayList(); + + /** + * No-arg constructor for deserialization. + */ + @SuppressWarnings("unused") + private Episode() { + this(0, 0); + } + + /** + * Creates a new episode. + * + * @param season + * The season of the episode + * @param episode + * The number of the episode + */ + public Episode(int season, int episode) { + this.season = season; + this.episode = episode; + } + + // + // ACCESSORS + // + + /** + * Returns the season of this episode. + * + * @return The season of this episode + */ + public int season() { + return season; + } + + /** + * Returns the number of this episode. + * + * @return The number of this episode + */ + public int episode() { + return episode; + } + + /** + * Returns the torrent files of this episode. + * + * @return The torrent files of this episode + */ + public Collection torrentFiles() { + return torrentFiles; + } + + /** + * Returns the identifier of this episode. + * + * @return The identifier of this episode + */ + public String identifier() { + return String.format("S%02dE%02d", season, episode); + } + + // + // ACTIONS + // + + /** + * Adds the given torrent file to this episode. + * + * @param torrentFile + * The torrent file to add + */ + public void addTorrentFile(TorrentFile torrentFile) { + torrentFiles.add(torrentFile); + } + + // + // ITERABLE METHODS + // + + /** + * {@inheritDoc} + */ + @Override + public Iterator iterator() { + return torrentFiles.iterator(); + } + + // + // OBJECT METHODS + // + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() { + return season * 65536 + episode; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean equals(Object obj) { + if (!(obj instanceof Episode)) { + return false; + } + Episode episode = (Episode) obj; + return (season == episode.season) && (this.episode == episode.episode); + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() { + return String.format("%s[season=%d,episode=%d,torrentFiles=%s]", getClass().getSimpleName(), season, episode, torrentFiles); + } + + } + +} diff --git a/src/main/java/net/pterodactylus/reactor/triggers/NewEpisodeTrigger.java b/src/main/java/net/pterodactylus/reactor/triggers/NewEpisodeTrigger.java new file mode 100644 index 0000000..55a866a --- /dev/null +++ b/src/main/java/net/pterodactylus/reactor/triggers/NewEpisodeTrigger.java @@ -0,0 +1,233 @@ +/* + * Reactor - 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 . + */ + +package net.pterodactylus.reactor.triggers; + +import static com.google.common.base.Preconditions.checkState; + +import java.util.Collection; + +import net.pterodactylus.reactor.Reaction; +import net.pterodactylus.reactor.State; +import net.pterodactylus.reactor.Trigger; +import net.pterodactylus.reactor.output.DefaultOutput; +import net.pterodactylus.reactor.output.Output; +import net.pterodactylus.reactor.states.EpisodeState; +import net.pterodactylus.reactor.states.EpisodeState.Episode; +import net.pterodactylus.reactor.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 David ‘Bombe’ Roden + */ +public class NewEpisodeTrigger implements Trigger { + + /** All new episodes. */ + private Collection newEpisodes; + + /** All changed episodes. */ + private Collection 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() { + + @Override + public boolean apply(Episode episode) { + return !previousEpisodeState.episodes().contains(episode); + } + }); + + changedEpisodes = Collections2.filter(currentEpisodeState.episodes(), new Predicate() { + + @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 newTorrentFiles = Collections2.filter(episode.torrentFiles(), new Predicate() { + + @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 “!”", 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 newEpisodes, Collection 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 (!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"); + stringBuilder.append(" Magnet: ").append(torrentFile.magnetUri()).append("\n"); + 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 newEpisodes, Collection changedEpisodes) { + StringBuilder htmlBuilder = new StringBuilder(); + htmlBuilder.append("\n"); + htmlBuilder.append("

").append(StringEscapeUtils.escapeHtml4(reaction.name())).append("

\n"); + if (!newEpisodes.isEmpty()) { + htmlBuilder.append("

New Episodes

\n"); + htmlBuilder.append("
    \n"); + for (Episode episode : newEpisodes) { + htmlBuilder.append("
  • Season ").append(episode.season()).append(", Episode ").append(episode.episode()).append("
  • \n"); + htmlBuilder.append("
      \n"); + for (TorrentFile torrentFile : episode) { + htmlBuilder.append("
    • ").append(StringEscapeUtils.escapeHtml4(torrentFile.name())).append("
    • \n"); + htmlBuilder.append("
      "); + htmlBuilder.append("").append(StringEscapeUtils.escapeHtml4(torrentFile.size())).append(", "); + htmlBuilder.append("").append(torrentFile.fileCount()).append(" file(s), "); + htmlBuilder.append("").append(torrentFile.seedCount()).append(" seed(s), "); + htmlBuilder.append("").append(torrentFile.leechCount()).append(" leecher(s)
      \n"); + htmlBuilder.append("
      Magnet "); + htmlBuilder.append("Download
      \n"); + } + htmlBuilder.append("
    \n"); + } + htmlBuilder.append("
\n"); + } + if (!changedEpisodes.isEmpty()) { + htmlBuilder.append("

Changed Episodes

\n"); + htmlBuilder.append("
    \n"); + for (Episode episode : changedEpisodes) { + htmlBuilder.append("
  • Season ").append(episode.season()).append(", Episode ").append(episode.episode()).append("
  • \n"); + htmlBuilder.append("
      \n"); + for (TorrentFile torrentFile : episode) { + htmlBuilder.append("
    • ").append(StringEscapeUtils.escapeHtml4(torrentFile.name())).append("
    • \n"); + htmlBuilder.append("
      "); + htmlBuilder.append("").append(StringEscapeUtils.escapeHtml4(torrentFile.size())).append(", "); + htmlBuilder.append("").append(torrentFile.fileCount()).append(" file(s), "); + htmlBuilder.append("").append(torrentFile.seedCount()).append(" seed(s), "); + htmlBuilder.append("").append(torrentFile.leechCount()).append(" leecher(s)
      \n"); + htmlBuilder.append("
      Magnet "); + htmlBuilder.append("Download
      \n"); + } + htmlBuilder.append("
    \n"); + } + htmlBuilder.append("
\n"); + } + htmlBuilder.append("\n"); + return htmlBuilder.toString(); + } + +} -- 2.7.4