diff --git a/build.xml b/build.xml index 4cf7099d..db5dfc21 100644 --- a/build.xml +++ b/build.xml @@ -1,6 +1,6 @@ - + @@ -22,15 +22,6 @@ - - - - - - - - - @@ -45,25 +36,31 @@ + + + + + + + - - - + + - + - + - + @@ -71,7 +68,7 @@ - + @@ -79,23 +76,27 @@ - + - + - + - + + + + + @@ -110,7 +111,7 @@ - + @@ -139,7 +140,7 @@ - + @@ -147,16 +148,16 @@ - + - - + + - + diff --git a/lib/sublight-ws.jar b/lib/sublight-ws.jar new file mode 100644 index 00000000..b465f0d9 Binary files /dev/null and b/lib/sublight-ws.jar differ diff --git a/source/net/sourceforge/filebot/resources/search.sublight.png b/source/net/sourceforge/filebot/resources/search.sublight.png new file mode 100644 index 00000000..662a00fc Binary files /dev/null and b/source/net/sourceforge/filebot/resources/search.sublight.png differ diff --git a/source/net/sourceforge/filebot/web/IMDbClient.java b/source/net/sourceforge/filebot/web/IMDbClient.java index 479e32a3..0f463e3e 100644 --- a/source/net/sourceforge/filebot/web/IMDbClient.java +++ b/source/net/sourceforge/filebot/web/IMDbClient.java @@ -62,8 +62,8 @@ public class IMDbClient implements EpisodeListProvider { List results = new ArrayList(nodes.size()); for (Node node : nodes) { - String name = removeQuotationMarks(node.getTextContent().trim()); - String year = node.getNextSibling().getTextContent().trim(); + String name = normalizeName(node.getTextContent().trim()); + String year = node.getNextSibling().getTextContent().trim().replaceAll("\\D+", ""); // remove non-number characters String href = getAttribute("href", node); results.add(new MovieDescriptor(name, Integer.parseInt(year), getImdbId(href))); @@ -71,7 +71,7 @@ public class IMDbClient implements EpisodeListProvider { // we might have been redirected to the movie page if (results.isEmpty()) { - String name = removeQuotationMarks(selectString("//H1/text()", dom)); + String name = normalizeName(selectString("//H1/text()", dom)); String year = selectString("//H1//A", dom); String url = selectString("//LINK[@rel='canonical']/@href", dom); @@ -86,7 +86,7 @@ public class IMDbClient implements EpisodeListProvider { public List getEpisodeList(SearchResult searchResult) throws IOException, SAXException { Document dom = getHtmlDocument(openConnection(getEpisodeListLink(searchResult).toURL())); - String seriesName = removeQuotationMarks(selectString("//H1/A", dom)); + String seriesName = normalizeName(selectString("//H1/A", dom)); List nodes = selectNodes("//TABLE//H3/A[preceding-sibling::text()]", dom); @@ -129,8 +129,9 @@ public class IMDbClient implements EpisodeListProvider { } - protected String removeQuotationMarks(String name) { - return name.replaceAll("^\"|\"$", ""); + protected String normalizeName(String name) { + // remove quotation marks + return name.replaceAll("\"", ""); } diff --git a/source/net/sourceforge/filebot/web/SublightSubtitleClient.java b/source/net/sourceforge/filebot/web/SublightSubtitleClient.java new file mode 100644 index 00000000..f2677cd3 --- /dev/null +++ b/source/net/sourceforge/filebot/web/SublightSubtitleClient.java @@ -0,0 +1,252 @@ + +package net.sourceforge.filebot.web; + + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import javax.swing.Icon; +import javax.xml.ws.Holder; +import javax.xml.ws.WebServiceException; + +import net.sourceforge.filebot.ResourceManager; +import net.sourceforge.tuned.Timer; +import net.sublight.webservice.ArrayOfGenre; +import net.sublight.webservice.ArrayOfIMDB; +import net.sublight.webservice.ArrayOfRelease; +import net.sublight.webservice.ArrayOfSubtitle; +import net.sublight.webservice.ArrayOfSubtitleLanguage; +import net.sublight.webservice.Genre; +import net.sublight.webservice.IMDB; +import net.sublight.webservice.Release; +import net.sublight.webservice.Subtitle; +import net.sublight.webservice.SubtitleLanguage; +import net.sublight.webservice.SubtitlesAPI2; +import net.sublight.webservice.SubtitlesAPI2Soap; + + +public class SublightSubtitleClient implements SubtitleProvider { + + private static final String iid = "42cc1701-3752-49e2-a148-332960073452"; + + private final String clientInfo; + + private final SubtitlesAPI2Soap webservice; + + private String session; + + + public SublightSubtitleClient(String clientInfo) { + this.clientInfo = clientInfo; + this.webservice = new SubtitlesAPI2().getSubtitlesAPI2Soap(); + } + + + @Override + public String getName() { + return "Sublight"; + } + + + @Override + public Icon getIcon() { + return ResourceManager.getIcon("search.sublight"); + } + + + @Override + public List search(String query) throws WebServiceException { + // require login + login(); + + Holder response = new Holder(); + Holder error = new Holder(); + + webservice.findIMDB(query, null, null, response, error); + + // abort if something went wrong + checkError(error); + + List results = new ArrayList(); + + if (response.value != null) { + for (IMDB imdb : response.value.getIMDB()) { + // remove classifier (e.g. tt0436992 -> 0436992) + int id = Integer.parseInt(imdb.getId().substring(2)); + + results.add(new MovieDescriptor(imdb.getTitle(), imdb.getYear(), id)); + } + } + + return results; + } + + + @Override + public List getSubtitleList(SearchResult searchResult, String languageName) throws WebServiceException { + MovieDescriptor movie = (MovieDescriptor) searchResult; + + List subtitles = new ArrayList(); + + // retrieve subtitles by name and year + for (Subtitle subtitle : getSubtitleList(null, movie.getName(), movie.getYear(), languageName)) { + subtitles.add(new SublightSubtitleDescriptor(subtitle)); + } + + return subtitles; + } + + + public List getSubtitleList(File videoFile, String languageName) throws WebServiceException, IOException { + List subtitles = new ArrayList(); + + // retrieve subtitles by video hash + for (Subtitle subtitle : getSubtitleList(SublightVideoHasher.computeHash(videoFile), null, null, languageName)) { + // only keep linked subtitles + if (subtitle.isIsLinked()) { + subtitles.add(new SublightSubtitleDescriptor(subtitle)); + } + } + + return subtitles; + } + + + protected List getSubtitleList(String videoHash, String name, Integer year, String languageName) throws WebServiceException { + // require login + login(); + + // given language or all languages + ArrayOfSubtitleLanguage languages = new ArrayOfSubtitleLanguage(); + + if (languageName != null) { + // given language + languages.getSubtitleLanguage().add(getSubtitleLanguage(languageName)); + } else { + // all languages + Collections.addAll(languages.getSubtitleLanguage(), SubtitleLanguage.values()); + } + + // all genres + ArrayOfGenre genres = new ArrayOfGenre(); + Collections.addAll(genres.getGenre(), Genre.values()); + + // response holders + Holder subtitles = new Holder(); + Holder releases = new Holder(); + Holder error = new Holder(); + + webservice.searchSubtitles3(session, videoHash, name, year, null, null, languages, genres, null, null, null, subtitles, releases, null, error); + + // abort if something went wrong + checkError(error); + + // return empty list if response is empty + if (subtitles.value == null) { + return Collections.emptyList(); + } + + // map all release names by subtitle id + if (releases.value != null) { + Map releaseNameBySubtitleID = new HashMap(); + + // map release names by subtitle id + for (Release release : releases.value.getRelease()) { + releaseNameBySubtitleID.put(release.getSubtitleID(), release.getName()); + } + + // set release names + for (Subtitle subtitle : subtitles.value.getSubtitle()) { + subtitle.setRelease(releaseNameBySubtitleID.get(subtitle.getSubtitleID())); + } + } + + return subtitles.value.getSubtitle(); + } + + + protected SubtitleLanguage getSubtitleLanguage(String languageName) { + for (SubtitleLanguage language : SubtitleLanguage.values()) { + if (language.value().equalsIgnoreCase(languageName)) + return language; + } + + // special language name handling + if (languageName.equalsIgnoreCase("Brazilian")) + return SubtitleLanguage.PORTUGUESE_BRAZIL; + if (languageName.equalsIgnoreCase("Bosnian")) + return SubtitleLanguage.BOSNIAN_LATIN; + if (languageName.equalsIgnoreCase("Serbian")) + return SubtitleLanguage.SERBIAN_LATIN; + + // unkown language + throw new IllegalArgumentException("Illegal language: " + languageName); + } + + + @Override + public URI getSubtitleListLink(SearchResult searchResult, String languageName) { + return null; + } + + + protected synchronized void login() throws WebServiceException { + if (session == null) { + Holder session = new Holder(); + Holder error = new Holder(); + + webservice.logInAnonymous3(clientInfo, iid, session, null, error); + + // abort if something went wrong + checkError(error); + + // start session + this.session = session.value; + } + + // reset timer + logoutTimer.set(10, TimeUnit.MINUTES, true); + } + + + protected synchronized void logout() throws WebServiceException { + if (session != null) { + Holder error = new Holder(); + + webservice.logOut(session, null, error); + + // abort if something went wrong + checkError(error); + + // stop session + this.session = null; + + // cancel timer + logoutTimer.cancel(); + } + } + + + protected void checkError(Holder error) throws WebServiceException { + if (error.value != null) { + throw new WebServiceException("Login failed: " + error.value); + } + } + + + protected final Timer logoutTimer = new Timer() { + + @Override + public void run() { + logout(); + } + }; + +} diff --git a/source/net/sourceforge/filebot/web/SublightSubtitleDescriptor.java b/source/net/sourceforge/filebot/web/SublightSubtitleDescriptor.java new file mode 100644 index 00000000..4ec4f69d --- /dev/null +++ b/source/net/sourceforge/filebot/web/SublightSubtitleDescriptor.java @@ -0,0 +1,60 @@ + +package net.sourceforge.filebot.web; + + +import net.sourceforge.tuned.DownloadTask; +import net.sublight.webservice.Subtitle; + + +public class SublightSubtitleDescriptor implements SubtitleDescriptor { + + private final Subtitle subtitle; + + + public SublightSubtitleDescriptor(Subtitle subtitle) { + this.subtitle = subtitle; + } + + + @Override + public String getName() { + // use release name by default + String releaseName = subtitle.getRelease(); + + if (releaseName == null || releaseName.isEmpty()) { + // create name from subtitle information (name, season, episode, ...) + String season = subtitle.getSeason() != null ? subtitle.getSeason().toString() : null; + String episode = subtitle.getEpisode() != null ? subtitle.getEpisode().toString() : null; + + return EpisodeFormat.getInstance().format(new Episode(subtitle.getTitle(), season, episode, null)); + } + + return releaseName; + } + + + @Override + public String getArchiveType() { + return subtitle.getSubtitleType().value().toLowerCase(); + } + + + @Override + public String getLanguageName() { + return subtitle.getLanguage().value(); + } + + + @Override + public DownloadTask createDownloadTask() { + // TODO support + return new DownloadTask(null); + } + + + @Override + public String toString() { + return String.format("%s [%s]", getName(), getLanguageName()); + } + +} diff --git a/source/net/sourceforge/filebot/web/SublightVideoHasher.java b/source/net/sourceforge/filebot/web/SublightVideoHasher.java new file mode 100644 index 00000000..d674d00f --- /dev/null +++ b/source/net/sourceforge/filebot/web/SublightVideoHasher.java @@ -0,0 +1,124 @@ + +package net.sourceforge.filebot.web; + + +import static java.lang.Math.*; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.nio.channels.FileChannel; +import java.nio.channels.FileChannel.MapMode; +import java.security.MessageDigest; +import java.util.Formatter; +import java.util.concurrent.TimeUnit; + +import net.sourceforge.filebot.mediainfo.MediaInfo; +import net.sourceforge.filebot.mediainfo.MediaInfo.StreamKind; + + +/** + * Compute special hash used by Sublight to identify video files. + * + *
+ * The hash is divided into 5 sections:
+ * 1 byte : reserved
+ * 2 bytes: video duration in seconds
+ * 6 bytes: file size in bytes
+ * 16 bytes: MD5 hash of the first 5 MB
+ * 1 byte: control byte, sum of all other bytes
+ * 
+ */ +public final class SublightVideoHasher { + + + public static String computeHash(File file) throws IOException { + byte[][] hash = new byte[4][]; + + // 1 byte = 0 (reserved) + hash[0] = new byte[] { 0 }; + + // 2 bytes (video duration in seconds) + hash[1] = getTrailingBytes(getDuration(file, TimeUnit.SECONDS), 2); + + // 6 bytes (file size in bytes) + hash[2] = getTrailingBytes(file.length(), 6); + + // 16 bytes (md5 hash of the first 5 MB) + hash[3] = getHeadMD5(file, 5 * 1024 * 1024); + + // format and sum + Formatter hex = new Formatter(new StringBuilder(52)); + byte sum = 0; + + for (byte[] group : hash) { + for (byte b : group) { + hex.format("%02x", b); + sum += b; + } + } + + // 1 byte (control byte) + hex.format("%02x", sum); + + // done + return hex.out().toString(); + } + + + protected static byte[] getTrailingBytes(long value, int n) { + byte[] bytes = BigInteger.valueOf(value).toByteArray(); + + // bytes will be initialized with 0 + byte[] trailingBytes = new byte[n]; + + // copy the least significant n bytes to the new array + System.arraycopy(bytes, max(0, bytes.length - n), trailingBytes, max(0, n - bytes.length), min(n, bytes.length)); + + return trailingBytes; + } + + + protected static long getDuration(File file, TimeUnit unit) throws IOException { + try { + MediaInfo mediaInfo = new MediaInfo(); + + if (!mediaInfo.open(file)) + throw new IllegalArgumentException("Failed to open file: " + file); + + // get media info + String duration = mediaInfo.get(StreamKind.General, 0, "Duration"); + + // close handle + mediaInfo.close(); + + // convert from milliseconds to given unit + return unit.convert(Long.parseLong(duration), TimeUnit.MILLISECONDS); + } catch (Exception e) { + throw new IOException("Failed to get video duration", e); + } + } + + + protected static byte[] getHeadMD5(File file, long chunkSize) throws IOException { + try { + MessageDigest md5 = MessageDigest.getInstance("MD5"); + + FileChannel channel = new FileInputStream(file).getChannel(); + + try { + // calculate md5 + md5.update(channel.map(MapMode.READ_ONLY, 0, min(channel.size(), chunkSize))); + } finally { + // close channel + channel.close(); + } + + return md5.digest(); + } catch (Exception e) { + throw new IOException("Failed to calculate md5 hash", e); + } + } + +} diff --git a/test/net/sourceforge/filebot/web/IMDbClientTest.java b/test/net/sourceforge/filebot/web/IMDbClientTest.java index bd3b03d3..954f7f1c 100644 --- a/test/net/sourceforge/filebot/web/IMDbClientTest.java +++ b/test/net/sourceforge/filebot/web/IMDbClientTest.java @@ -87,8 +87,8 @@ public class IMDbClientTest { @Test public void removeQuotationMarks() throws Exception { - assertEquals("test", imdb.removeQuotationMarks("\"test\"")); + assertEquals("test", imdb.normalizeName("\"test\"")); - assertEquals("inner \"quotation marks\"", imdb.removeQuotationMarks("\"inner \"quotation marks\"\"")); + assertEquals("inner \"quotation marks\"", imdb.normalizeName("\"inner \"quotation marks\"\"")); } } diff --git a/test/net/sourceforge/filebot/web/OpenSubtitlesHasherTest.java b/test/net/sourceforge/filebot/web/OpenSubtitlesHasherTest.java index 5081caa6..242c440d 100644 --- a/test/net/sourceforge/filebot/web/OpenSubtitlesHasherTest.java +++ b/test/net/sourceforge/filebot/web/OpenSubtitlesHasherTest.java @@ -9,7 +9,6 @@ import java.io.FileInputStream; import java.util.Arrays; import java.util.Collection; -import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; @@ -17,13 +16,12 @@ import org.junit.runners.Parameterized.Parameters; @RunWith(Parameterized.class) -@Ignore("No test data") public class OpenSubtitlesHasherTest { private String expectedHash; private File file; - + public OpenSubtitlesHasherTest(String expectedHash, File file) { this.file = file; this.expectedHash = expectedHash; diff --git a/test/net/sourceforge/filebot/web/SublightSubtitleClientTest.java b/test/net/sourceforge/filebot/web/SublightSubtitleClientTest.java new file mode 100644 index 00000000..140e6b22 --- /dev/null +++ b/test/net/sourceforge/filebot/web/SublightSubtitleClientTest.java @@ -0,0 +1,91 @@ + +package net.sourceforge.filebot.web; + + +import static org.junit.Assert.*; + +import java.util.List; + +import net.sublight.webservice.Subtitle; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + + +public class SublightSubtitleClientTest { + + private static SublightSubtitleClient client = new SublightSubtitleClient("Test;0.0"); + + + @BeforeClass + public static void login() { + // login manually + client.login(); + } + + + @Test + public void search() { + List list = client.search("babylon 5"); + + MovieDescriptor sample = (MovieDescriptor) list.get(0); + + // check sample entry + assertEquals("Babylon 5", sample.getName()); + assertEquals(105946, sample.getImdbId()); + + // check size + assertEquals(8, list.size()); + } + + + @Test + public void getSubtitleListEnglish() { + List list = client.getSubtitleList(new MovieDescriptor("Heroes", 2006, 813715), "English"); + + SubtitleDescriptor sample = list.get(0); + + assertTrue(sample.getName().startsWith("Heroes")); + assertEquals("English", sample.getLanguageName()); + + // check size + assertTrue(list.size() > 45); + } + + + @Test + public void getSubtitleListAllLanguages() { + List list = client.getSubtitleList(new MovieDescriptor("Babylon 5", 1994, 105946), null); + + SubtitleDescriptor sample = list.get(0); + + assertEquals("Babylon.5.S01E01.Midnight.on.the.Firing.Line.AC3.DVDRip.DivX-AMC", sample.getName()); + assertEquals("Slovenian", sample.getLanguageName()); + + // check size + assertTrue(list.size() > 45); + } + + + @Test + public void getSubtitleListVideoHash() { + List list = client.getSubtitleList("000a20000045eacfebd3c2c83bfb4ea1598b14e9be7db38316fd", null, null, "English"); + + Subtitle sample = list.get(0); + + assertEquals("Terminator: The Sarah Connor Chronicles", sample.getTitle()); + assertEquals(2, sample.getSeason(), 0); + assertEquals(22, sample.getEpisode(), 0); + assertEquals("Terminator.The.Sarah.Connor.Chronicles.S02E22.HDTV.XviD-2HD", sample.getRelease()); + assertTrue(sample.isIsLinked()); + } + + + @AfterClass + public static void logout() { + // logout manually + client.logout(); + } + +} diff --git a/test/net/sourceforge/filebot/web/WebTestSuite.java b/test/net/sourceforge/filebot/web/WebTestSuite.java index 37993dc0..188e5711 100644 --- a/test/net/sourceforge/filebot/web/WebTestSuite.java +++ b/test/net/sourceforge/filebot/web/WebTestSuite.java @@ -8,7 +8,7 @@ import org.junit.runners.Suite.SuiteClasses; @RunWith(Suite.class) -@SuiteClasses( { TVDotComClientTest.class, AnidbClientTest.class, TVRageClientTest.class, TheTVDBClientTest.class, IMDbClientTest.class, SubsceneSubtitleClientTest.class, SubtitleSourceClientTest.class, OpenSubtitlesHasherTest.class }) +@SuiteClasses( { TVDotComClientTest.class, AnidbClientTest.class, TVRageClientTest.class, TheTVDBClientTest.class, IMDbClientTest.class, SubsceneSubtitleClientTest.class, SubtitleSourceClientTest.class, SublightSubtitleClientTest.class }) public class WebTestSuite { }