filebot/source/net/filebot/web/TMDbClient.java

405 lines
15 KiB
Java
Raw Normal View History

2014-04-19 02:30:29 -04:00
package net.filebot.web;
2013-09-11 13:22:00 -04:00
import static java.util.Collections.*;
2016-03-07 14:46:47 -05:00
import static java.util.stream.Collectors.*;
2016-03-08 12:47:17 -05:00
import static net.filebot.CachedResource.*;
2016-03-07 14:46:47 -05:00
import static net.filebot.Logging.*;
2016-04-11 17:23:10 -04:00
import static net.filebot.similarity.Normalization.*;
2016-03-07 14:46:47 -05:00
import static net.filebot.util.JsonUtilities.*;
2016-01-09 23:54:35 -05:00
import static net.filebot.util.StringUtilities.*;
2014-04-19 02:30:29 -04:00
import static net.filebot.web.WebRequest.*;
import java.io.FileNotFoundException;
import java.net.URI;
import java.net.URL;
2016-07-18 15:29:31 -04:00
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
2016-03-07 14:46:47 -05:00
import java.util.Objects;
import java.util.Set;
2012-04-08 04:41:48 -04:00
import java.util.concurrent.TimeUnit;
2016-05-09 10:59:21 -04:00
import java.util.function.Function;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
2016-03-07 14:46:47 -05:00
import java.util.stream.Stream;
import javax.swing.Icon;
import net.filebot.Cache;
import net.filebot.CacheType;
2014-04-19 02:30:29 -04:00
import net.filebot.ResourceManager;
public class TMDbClient implements MovieIdentificationService, ArtworkProvider {
// X-RateLimit: 40 requests per 10 seconds => https://developers.themoviedb.org/3/getting-started/request-rate-limiting
private static final FloodLimit REQUEST_LIMIT = new FloodLimit(35, 10, TimeUnit.SECONDS);
private final String host = "api.themoviedb.org";
private final String version = "3";
private String apikey;
private boolean adult;
public TMDbClient(String apikey, boolean adult) {
this.apikey = apikey;
this.adult = adult;
}
@Override
public String getName() {
return "TheMovieDB";
}
@Override
public Icon getIcon() {
return ResourceManager.getIcon("search.themoviedb");
}
protected Matcher getNameYearMatcher(String query) {
2016-03-26 13:40:59 -04:00
return Pattern.compile("(.+)\\b[(]?((?:19|20)\\d{2})[)]?$").matcher(query.trim());
}
@Override
public List<Movie> searchMovie(String query, Locale locale) throws Exception {
// query by name with year filter if possible
2016-03-26 13:40:59 -04:00
Matcher nameYear = getNameYearMatcher(query);
if (nameYear.matches()) {
return searchMovie(nameYear.group(1).trim(), Integer.parseInt(nameYear.group(2)), locale, false);
} else {
2016-03-26 13:40:59 -04:00
return searchMovie(query.trim(), -1, locale, false);
}
}
public List<Movie> searchMovie(String movieName, int movieYear, Locale locale, boolean extendedInfo) throws Exception {
// ignore queries that are too short to yield good results
if (movieName.length() < 3 && !(movieName.length() >= 1 && movieYear > 0)) {
return emptyList();
}
2016-03-26 13:40:59 -04:00
Map<String, Object> query = new LinkedHashMap<String, Object>(2);
query.put("query", movieName);
if (movieYear > 0) {
2016-03-26 13:40:59 -04:00
query.put("year", movieYear);
}
if (adult) {
query.put("include_adult", adult);
}
Object response = request("search/movie", query, locale);
2016-03-07 14:46:47 -05:00
// e.g. {"id":16320,"title":"冲出宁静号","release_date":"2005-09-30","original_title":"Serenity"}
return streamJsonObjects(response, "results").map(it -> {
int id = -1, year = -1;
2016-03-11 15:05:46 -05:00
try {
id = getDecimal(it, "id").intValue();
year = matchInteger(getString(it, "release_date")); // release date is often missing
} catch (Exception e) {
2016-03-13 13:35:27 -04:00
debug.fine(format("Missing data: release_date => %s", it));
2016-03-11 15:05:46 -05:00
return null;
}
2016-03-07 14:46:47 -05:00
String title = getString(it, "title");
String originalTitle = getString(it, "original_title");
if (title == null) {
title = originalTitle;
}
2016-04-10 14:37:13 -04:00
String[] alternativeTitles = getAlternativeTitles("movie/" + id, "titles", title, originalTitle, extendedInfo);
2016-04-10 14:37:13 -04:00
return new Movie(title, alternativeTitles, year, -1, id, locale);
2016-03-26 13:40:59 -04:00
}).filter(Objects::nonNull).collect(toList());
}
2016-04-10 14:37:13 -04:00
protected String[] getAlternativeTitles(String path, String key, String title, String originalTitle, boolean extendedInfo) {
2016-03-26 13:40:59 -04:00
Set<String> alternativeTitles = new LinkedHashSet<String>();
if (originalTitle != null) {
alternativeTitles.add(originalTitle);
}
if (extendedInfo) {
try {
Object response = request(path + "/alternative_titles", emptyMap(), Locale.ENGLISH);
2016-03-26 13:40:59 -04:00
streamJsonObjects(response, key).map(n -> {
return getString(n, "title");
}).filter(Objects::nonNull).filter(n -> n.length() >= 2).forEach(alternativeTitles::add);
} catch (Exception e) {
debug.warning(format("Failed to fetch alternative titles for %s => %s", path, e));
2016-03-11 15:05:46 -05:00
}
2016-03-26 13:40:59 -04:00
}
2016-03-26 13:40:59 -04:00
// make sure main title is not in the set of alternative titles
alternativeTitles.remove(title);
2016-04-10 14:37:13 -04:00
return alternativeTitles.toArray(new String[0]);
}
public URI getMoviePageLink(int tmdbid) {
2016-03-26 13:40:59 -04:00
return URI.create("https://www.themoviedb.org/movie/" + tmdbid);
}
@Override
public Movie getMovieDescriptor(Movie id, Locale locale) throws Exception {
2014-09-17 11:44:23 -04:00
if (id.getTmdbId() > 0 || id.getImdbId() > 0) {
MovieInfo info = getMovieInfo(id, locale, false);
2015-02-26 10:53:50 -05:00
if (info != null) {
String name = info.getName();
String[] aliasNames = info.getOriginalName() == null || info.getOriginalName().isEmpty() || info.getOriginalName().equals(name) ? new String[0] : new String[] { info.getOriginalName() };
int year = info.getReleased() != null ? info.getReleased().getYear() : id.getYear();
int tmdbid = info.getId();
2016-05-12 12:10:12 -04:00
int imdbid = info.getImdbId() != null ? info.getImdbId() : 0;
2015-02-26 10:53:50 -05:00
return new Movie(name, aliasNames, year, imdbid, tmdbid, locale);
}
}
2014-09-17 11:44:23 -04:00
return null;
}
public MovieInfo getMovieInfo(Movie movie, Locale locale, boolean extendedInfo) throws Exception {
2014-09-17 11:44:23 -04:00
try {
if (movie.getTmdbId() > 0) {
return getMovieInfo(String.valueOf(movie.getTmdbId()), locale, extendedInfo);
} else if (movie.getImdbId() > 0) {
return getMovieInfo(String.format("tt%07d", movie.getImdbId()), locale, extendedInfo);
}
2014-09-17 11:44:23 -04:00
} catch (FileNotFoundException | NullPointerException e) {
2016-03-09 15:36:28 -05:00
debug.log(Level.WARNING, String.format("Movie data not found: %s [%d / %d]", movie, movie.getTmdbId(), movie.getImdbId()));
}
return null;
2012-01-02 10:27:20 -05:00
}
public MovieInfo getMovieInfo(String id, Locale locale, boolean extendedInfo) throws Exception {
Object response = request("movie/" + id, extendedInfo ? singletonMap("append_to_response", "alternative_titles,releases,casts,trailers") : emptyMap(), locale);
2016-08-11 14:04:41 -04:00
// read all basic movie properties
2017-02-08 10:17:05 -05:00
Map<MovieInfo.Property, String> fields = getEnumMap(response, MovieInfo.Property.class);
2016-08-11 14:04:41 -04:00
// fix poster path
try {
2017-02-08 10:17:05 -05:00
fields.computeIfPresent(MovieInfo.Property.poster_path, (k, v) -> extendedInfo ? resolveImage(v).toString() : null);
2016-08-11 14:04:41 -04:00
} catch (Exception e) {
// movie does not belong to any collection
debug.warning(format("Bad data: poster_path => %s", response));
}
try {
2016-03-07 14:46:47 -05:00
Map<?, ?> collection = getMap(response, "belongs_to_collection");
2017-02-08 10:17:05 -05:00
fields.put(MovieInfo.Property.collection, getString(collection, "name"));
} catch (Exception e) {
2016-03-07 14:46:47 -05:00
// movie does not belong to any collection
debug.warning(format("Bad data: belongs_to_collection => %s", response));
}
List<String> genres = new ArrayList<String>();
try {
2016-03-07 14:46:47 -05:00
streamJsonObjects(response, "genres").map(it -> getString(it, "name")).filter(Objects::nonNull).forEach(genres::add);
} catch (Exception e) {
2016-03-07 14:46:47 -05:00
debug.warning(format("Bad data: genres => %s", response));
}
List<String> spokenLanguages = new ArrayList<String>();
try {
2016-03-07 14:46:47 -05:00
streamJsonObjects(response, "spoken_languages").map(it -> getString(it, "iso_639_1")).filter(Objects::nonNull).forEach(spokenLanguages::add);
} catch (Exception e) {
2016-03-07 14:46:47 -05:00
debug.warning(format("Bad data: spoken_languages => %s", response));
}
2014-09-20 14:37:42 -04:00
List<String> productionCountries = new ArrayList<String>();
try {
2016-03-07 14:46:47 -05:00
streamJsonObjects(response, "production_countries").map(it -> getString(it, "iso_3166_1")).filter(Objects::nonNull).forEach(productionCountries::add);
2014-09-20 14:37:42 -04:00
} catch (Exception e) {
2016-03-07 14:46:47 -05:00
debug.warning(format("Bad data: production_countries => %s", response));
2014-09-20 14:37:42 -04:00
}
List<String> productionCompanies = new ArrayList<String>();
try {
2016-03-07 14:46:47 -05:00
streamJsonObjects(response, "production_companies").map(it -> getString(it, "name")).filter(Objects::nonNull).forEach(productionCompanies::add);
} catch (Exception e) {
2016-03-07 14:46:47 -05:00
debug.warning(format("Bad data: production_companies => %s", response));
}
List<String> alternativeTitles = new ArrayList<String>();
try {
2016-03-07 14:46:47 -05:00
streamJsonObjects(getMap(response, "alternative_titles"), "titles").map(it -> getString(it, "title")).filter(Objects::nonNull).forEach(alternativeTitles::add);
} catch (Exception e) {
2016-03-07 14:46:47 -05:00
debug.warning(format("Bad data: alternative_titles => %s", response));
}
2016-03-07 14:46:47 -05:00
Map<String, String> certifications = new LinkedHashMap<String, String>();
try {
String countryCode = locale.getCountry().isEmpty() ? "US" : locale.getCountry();
2016-03-07 14:46:47 -05:00
streamJsonObjects(getMap(response, "releases"), "countries").forEach(it -> {
String certificationCountry = getString(it, "iso_3166_1");
String certification = getString(it, "certification");
if (certification != null && certificationCountry != null) {
// add country specific certification code
if (countryCode.equals(certificationCountry)) {
2017-02-08 10:17:05 -05:00
fields.put(MovieInfo.Property.certification, certification);
}
2016-03-07 14:46:47 -05:00
// collect all certification codes just in case
certifications.put(certificationCountry, certification);
}
2016-03-07 14:46:47 -05:00
});
} catch (Exception e) {
2016-03-07 14:46:47 -05:00
debug.warning(format("Bad data: certification => %s", response));
}
List<Person> cast = new ArrayList<Person>();
try {
2016-05-09 10:59:21 -04:00
// { "cast_id":20, "character":"Gandalf", "credit_id":"52fe4a87c3a368484e158bb7", "id":1327, "name":"Ian McKellen", "order":1, "profile_path":"/c51mP46oPgAgFf7bFWVHlScZynM.jpg" }
Function<String, String> normalize = s -> replaceSpace(s, " ").trim(); // user data may not be well-formed
2016-03-07 14:46:47 -05:00
Stream.of("cast", "crew").flatMap(section -> streamJsonObjects(getMap(response, "casts"), section)).map(it -> {
2016-05-09 10:59:21 -04:00
String name = getStringValue(it, "name", normalize);
String character = getStringValue(it, "character", normalize);
String job = getStringValue(it, "job", normalize);
String department = getStringValue(it, "department", normalize);
Integer order = getInteger(it, "order");
URL image = getStringValue(it, "profile_path", this::resolveImage);
return new Person(name, character, job, department, order, image);
}).sorted(Person.CREDIT_ORDER).forEach(cast::add);
} catch (Exception e) {
2016-03-07 14:46:47 -05:00
debug.warning(format("Bad data: casts => %s", response));
}
List<Trailer> trailers = new ArrayList<Trailer>();
2014-08-27 14:20:22 -04:00
try {
2016-03-07 14:46:47 -05:00
Stream.of("quicktime", "youtube").forEach(section -> {
streamJsonObjects(getMap(response, "trailers"), section).map(it -> {
Map<String, String> sources = new LinkedHashMap<String, String>();
Stream.concat(Stream.of(it), streamJsonObjects(it, "sources")).forEach(source -> {
String size = getString(source, "size");
if (size != null) {
sources.put(size, getString(source, "source"));
2014-08-27 14:20:22 -04:00
}
2016-03-07 14:46:47 -05:00
});
return new Trailer(section, getString(it, "name"), sources);
}).forEach(trailers::add);
});
2014-08-27 14:20:22 -04:00
} catch (Exception e) {
2016-03-07 14:46:47 -05:00
debug.warning(format("Bad data: trailers => %s", response));
}
return new MovieInfo(fields, alternativeTitles, genres, certifications, spokenLanguages, productionCountries, productionCompanies, cast, trailers);
}
@Override
public List<Artwork> getArtwork(int id, String category, Locale locale) throws Exception {
Object images = request("movie/" + id + "/images", emptyMap(), Locale.ROOT);
return streamJsonObjects(images, category).map(it -> {
2016-05-09 10:59:21 -04:00
URL image = getStringValue(it, "file_path", this::resolveImage);
String width = getString(it, "width");
String height = getString(it, "height");
Locale language = getStringValue(it, "iso_639_1", Locale::new);
2016-05-09 10:59:21 -04:00
return new Artwork(Stream.of(category, String.join("x", width, height)), image, language, null);
}).sorted(Artwork.RATING_ORDER).collect(toList());
}
protected Object getConfiguration() throws Exception {
return request("configuration", emptyMap(), Locale.ROOT);
2016-05-09 10:59:21 -04:00
}
protected URL resolveImage(String path) {
if (path == null || path.isEmpty()) {
return null;
}
try {
String mirror = (String) Cache.getCache(getName(), CacheType.Monthly).computeIfAbsent("configuration.base_url", it -> {
return getString(getMap(getConfiguration(), "images"), "base_url");
});
return new URL(mirror + "original" + path);
} catch (Exception e) {
throw new IllegalArgumentException(path, e);
}
}
public Map<String, List<String>> getAlternativeTitles(int id) throws Exception {
Object titles = request("movie/" + id + "/alternative_titles", emptyMap(), Locale.ROOT);
return streamJsonObjects(titles, "titles").collect(groupingBy(it -> {
return getString(it, "iso_3166_1");
}, mapping(it -> {
return getString(it, "title");
}, toList())));
}
2016-07-18 15:29:31 -04:00
public List<Movie> discover(LocalDate startDate, LocalDate endDate, Locale locale) throws Exception {
2016-07-20 03:30:26 -04:00
Map<String, Object> parameters = new LinkedHashMap<String, Object>(3);
parameters.put("primary_release_date.gte", startDate);
parameters.put("primary_release_date.lte", endDate);
parameters.put("sort_by", "popularity.desc");
return discover(parameters, locale);
}
public List<Movie> discover(int year, Locale locale) throws Exception {
Map<String, Object> parameters = new LinkedHashMap<String, Object>(2);
parameters.put("primary_release_year", year);
parameters.put("sort_by", "vote_count.desc");
return discover(parameters, locale);
}
public List<Movie> discover(Map<String, Object> parameters, Locale locale) throws Exception {
Object json = request("discover/movie", parameters, locale);
2016-07-18 15:29:31 -04:00
return streamJsonObjects(json, "results").map(it -> {
String title = getString(it, "title");
int year = getStringValue(it, "release_date", SimpleDate::parse).getYear();
int id = getInteger(it, "id");
2016-10-09 04:19:39 -04:00
return new Movie(title, null, year, 0, id, locale);
2016-07-18 15:29:31 -04:00
}).collect(toList());
}
protected Object request(String resource, Map<String, Object> parameters, Locale locale) throws Exception {
// default parameters
String key = parameters.isEmpty() ? resource : resource + '?' + encodeParameters(parameters, true);
2017-01-13 15:32:42 -05:00
String language = getLanguageCode(locale);
String cacheName = language == null ? getName() : getName() + "_" + language;
Cache cache = Cache.getCache(cacheName, CacheType.Monthly);
2017-01-13 15:32:42 -05:00
Object json = cache.json(key, k -> getResource(k, language)).fetch(withPermit(fetchIfNoneMatch(url -> key, cache), r -> REQUEST_LIMIT.acquirePermit())).expire(Cache.ONE_WEEK).get();
2016-03-07 18:56:32 -05:00
if (asMap(json).isEmpty()) {
2017-01-13 15:32:42 -05:00
throw new FileNotFoundException(String.format("Resource is empty: %s => %s", json, getResource(key, language)));
}
2016-03-07 18:56:32 -05:00
return json;
}
2017-01-13 15:32:42 -05:00
protected URL getResource(String path, String language) throws Exception {
StringBuilder file = new StringBuilder();
file.append('/').append(version);
file.append('/').append(path);
file.append(path.lastIndexOf('?') < 0 ? '?' : '&');
2017-01-13 15:32:42 -05:00
if (language != null) {
file.append("language=").append(language).append('&');
}
file.append("api_key=").append(apikey);
return new URL("https", host, file.toString());
}
protected String getLanguageCode(Locale locale) {
// require 2-letter language code
String language = locale.getLanguage();
if (language.length() == 2) {
if (locale.getCountry().length() == 2) {
return locale.getLanguage() + '-' + locale.getCountry(); // e.g. es-MX
}
return locale.getLanguage(); // e.g. en
}
2017-01-13 15:32:42 -05:00
return null;
}
}