mirror of
https://github.com/mitb-archive/filebot
synced 2024-12-24 08:48:51 -05:00
+ Switch to new TheTVDB API
This commit is contained in:
parent
69db530d8a
commit
71e84c661f
@ -1,48 +1,41 @@
|
|||||||
package net.filebot.web;
|
package net.filebot.web;
|
||||||
|
|
||||||
|
import static java.nio.charset.StandardCharsets.*;
|
||||||
|
import static java.util.Arrays.*;
|
||||||
|
import static java.util.Collections.*;
|
||||||
import static java.util.stream.Collectors.*;
|
import static java.util.stream.Collectors.*;
|
||||||
|
import static net.filebot.CachedResource.fetchIfModified;
|
||||||
import static net.filebot.Logging.*;
|
import static net.filebot.Logging.*;
|
||||||
import static net.filebot.util.RegularExpressions.*;
|
import static net.filebot.util.JsonUtilities.*;
|
||||||
import static net.filebot.util.StringUtilities.*;
|
import static net.filebot.util.StringUtilities.*;
|
||||||
import static net.filebot.util.XPathUtilities.*;
|
|
||||||
import static net.filebot.web.EpisodeUtilities.*;
|
import static net.filebot.web.EpisodeUtilities.*;
|
||||||
import static net.filebot.web.WebRequest.*;
|
import static net.filebot.web.WebRequest.*;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.EnumMap;
|
|
||||||
import java.util.EnumSet;
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Map.Entry;
|
|
||||||
import java.util.Random;
|
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import javax.swing.Icon;
|
import javax.swing.Icon;
|
||||||
|
|
||||||
import org.w3c.dom.Document;
|
|
||||||
import org.w3c.dom.Node;
|
|
||||||
|
|
||||||
import net.filebot.Cache;
|
import net.filebot.Cache;
|
||||||
import net.filebot.Cache.TypedCache;
|
|
||||||
import net.filebot.CacheType;
|
import net.filebot.CacheType;
|
||||||
import net.filebot.ResourceManager;
|
import net.filebot.ResourceManager;
|
||||||
|
|
||||||
public class TheTVDBClient extends AbstractEpisodeListProvider implements ArtworkProvider {
|
public class TheTVDBClient extends AbstractEpisodeListProvider implements ArtworkProvider {
|
||||||
|
|
||||||
private final Map<MirrorType, String> mirrors = MirrorType.newMap();
|
private String apikey;
|
||||||
|
|
||||||
private final String apikey;
|
|
||||||
|
|
||||||
public TheTVDBClient(String apikey) {
|
public TheTVDBClient(String apikey) {
|
||||||
if (apikey == null)
|
|
||||||
throw new NullPointerException("apikey must not be null");
|
|
||||||
|
|
||||||
this.apikey = apikey;
|
this.apikey = apikey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,137 +54,147 @@ public class TheTVDBClient extends AbstractEpisodeListProvider implements Artwor
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getLanguageCode(Locale locale) {
|
protected Object postJson(String path, Object json) throws Exception {
|
||||||
String code = locale.getLanguage();
|
// curl -X POST --header 'Content-Type: application/json' --header 'Accept: application/json' 'https://api.thetvdb.com/login' --data '{"apikey":"XXXXX"}'
|
||||||
|
ByteBuffer response = post(getEndpoint(path), asJsonString(json).getBytes(UTF_8), "application/json", null);
|
||||||
|
return readJson(UTF_8.decode(response));
|
||||||
|
}
|
||||||
|
|
||||||
// sanity check
|
protected Object requestJson(String path, Locale locale, Duration expirationTime) throws Exception {
|
||||||
if (code.length() != 2) {
|
Cache cache = Cache.getCache(locale == null || locale == Locale.ROOT ? getName() : getName() + "_" + locale.getLanguage(), CacheType.Monthly);
|
||||||
// see http://thetvdb.com/api/BA864DEE427E384A/languages.xml
|
return cache.json(path, this::getEndpoint).fetch(fetchIfModified(() -> getRequestHeader(locale))).expire(expirationTime).get();
|
||||||
throw new IllegalArgumentException("Expecting 2-letter language code: " + code);
|
}
|
||||||
|
|
||||||
|
protected URL getEndpoint(String path) throws Exception {
|
||||||
|
return new URL("https://api.thetvdb.com/" + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> getRequestHeader(Locale locale) {
|
||||||
|
Map<String, String> header = new LinkedHashMap<String, String>(3);
|
||||||
|
if (locale != null && locale != Locale.ROOT) {
|
||||||
|
header.put("Accept-Language", locale.getLanguage());
|
||||||
}
|
}
|
||||||
|
header.put("Accept", "application/json");
|
||||||
|
header.put("Authorization", "Bearer " + getAuthorizationToken());
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
// Java language code => TheTVDB language code
|
private String token = null;
|
||||||
if (code.equals("iw")) // Hebrew
|
private Instant tokenExpireInstant = null;
|
||||||
return "he";
|
private Duration tokenExpireDuration = Duration.ofHours(1);
|
||||||
if (code.equals("hi")) // Hungarian
|
|
||||||
return "hu";
|
|
||||||
if (code.equals("in")) // Indonesian
|
|
||||||
return "id";
|
|
||||||
if (code.equals("ro")) // Russian
|
|
||||||
return "ru";
|
|
||||||
|
|
||||||
return code;
|
private String getAuthorizationToken() {
|
||||||
|
synchronized (tokenExpireDuration) {
|
||||||
|
if (token == null || (tokenExpireInstant != null && Instant.now().isAfter(tokenExpireInstant))) {
|
||||||
|
try {
|
||||||
|
Object json = postJson("login", singletonMap("apikey", apikey));
|
||||||
|
token = getString(json, "token");
|
||||||
|
tokenExpireInstant = Instant.now().plus(tokenExpireDuration);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to retrieve authorization token: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected String[] languages() throws Exception {
|
||||||
|
Object response = requestJson("languages", Locale.ROOT, Cache.ONE_MONTH);
|
||||||
|
return streamJsonObjects(response, "data").map(it -> getString(it, "abbreviation")).toArray(String[]::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected List<SearchResult> search(String path, Map<String, Object> query, Locale locale, Duration expirationTime) throws Exception {
|
||||||
|
Object json = requestJson(path + "?" + encodeParameters(query, true), locale, expirationTime);
|
||||||
|
|
||||||
|
return streamJsonObjects(json, "data").map(it -> {
|
||||||
|
// e.g. aliases, banner, firstAired, id, network, overview, seriesName, status
|
||||||
|
int id = getInteger(it, "id");
|
||||||
|
String seriesName = getString(it, "seriesName");
|
||||||
|
String[] aliasNames = stream(getArray(it, "aliases")).toArray(String[]::new);
|
||||||
|
|
||||||
|
if (seriesName.startsWith("**") && seriesName.endsWith("**")) {
|
||||||
|
debug.fine(format("Invalid series: %s [%d]", seriesName, id));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SearchResult(id, seriesName, aliasNames);
|
||||||
|
}).filter(Objects::nonNull).collect(toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<SearchResult> fetchSearchResult(String query, Locale locale) throws Exception {
|
public List<SearchResult> fetchSearchResult(String query, Locale locale) throws Exception {
|
||||||
// perform online search
|
return search("search/series", singletonMap("name", query), locale, Cache.ONE_DAY);
|
||||||
Document dom = getXmlResource(MirrorType.SEARCH, "GetSeries.php?seriesname=" + encode(query, true) + "&language=" + getLanguageCode(locale));
|
}
|
||||||
|
|
||||||
Map<Integer, SearchResult> resultSet = new LinkedHashMap<Integer, SearchResult>();
|
@Override
|
||||||
|
public SeriesInfo getSeriesInfo(SearchResult series, Locale locale) throws Exception {
|
||||||
|
Object json = requestJson("series/" + series.getId(), locale, Cache.ONE_WEEK);
|
||||||
|
Object data = getMap(json, "data");
|
||||||
|
|
||||||
for (Node node : selectNodes("Data/Series", dom)) {
|
SeriesInfo info = new SeriesInfo(this, locale, series.getId());
|
||||||
int sid = matchInteger(getTextContent("seriesid", node));
|
info.setAliasNames(Stream.of(series.getAliasNames(), getArray(data, "aliases")).flatMap(it -> stream(it)).map(Object::toString).distinct().toArray(String[]::new));
|
||||||
String seriesName = getTextContent("SeriesName", node);
|
|
||||||
|
|
||||||
if (seriesName.startsWith("**") && seriesName.endsWith("**")) {
|
info.setName(getString(data, "seriesName"));
|
||||||
debug.fine(format("Invalid series: %s [%d]", seriesName, sid));
|
info.setCertification(getString(data, "rating"));
|
||||||
continue;
|
info.setNetwork(getString(data, "network"));
|
||||||
}
|
info.setStatus(getString(data, "status"));
|
||||||
|
|
||||||
// collect alias names
|
info.setRating(getDecimal(data, "siteRating"));
|
||||||
List<String> aliasNames = streamNodes("AliasNames", node).flatMap(it -> {
|
info.setRatingCount(getInteger(data, "siteRatingCount")); // TODO rating count not implemented in the new API yet
|
||||||
return PIPE.splitAsStream(getTextContent(it));
|
|
||||||
}).map(String::trim).filter(s -> s.length() > 0).collect(toList());
|
|
||||||
|
|
||||||
if (!resultSet.containsKey(sid)) {
|
info.setRuntime(matchInteger(getString(data, "runtime")));
|
||||||
resultSet.put(sid, new SearchResult(sid, seriesName, aliasNames));
|
info.setGenres(stream(getArray(data, "genre")).map(Object::toString).collect(toList()));
|
||||||
}
|
info.setStartDate(getStringValue(data, "firstAired", SimpleDate::parse));
|
||||||
}
|
|
||||||
|
|
||||||
return new ArrayList<SearchResult>(resultSet.values());
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected SeriesData fetchSeriesData(SearchResult series, SortOrder sortOrder, Locale locale) throws Exception {
|
protected SeriesData fetchSeriesData(SearchResult series, SortOrder sortOrder, Locale locale) throws Exception {
|
||||||
Document dom = getXmlResource(MirrorType.XML, "series/" + series.getId() + "/all/" + getLanguageCode(locale) + ".xml");
|
// fetch series info
|
||||||
|
SeriesInfo info = getSeriesInfo(series, locale);
|
||||||
|
info.setOrder(sortOrder.name());
|
||||||
|
|
||||||
// parse series info
|
// fetch episode data
|
||||||
Node seriesNode = selectNode("Data/Series", dom);
|
List<Episode> episodes = new ArrayList<Episode>();
|
||||||
TheTVDBSeriesInfo seriesInfo = new TheTVDBSeriesInfo(this, sortOrder, locale, series.getId());
|
List<Episode> specials = new ArrayList<Episode>();
|
||||||
seriesInfo.setAliasNames(series.getAliasNames());
|
|
||||||
|
|
||||||
seriesInfo.setName(getTextContent("SeriesName", seriesNode));
|
for (int page = 1, lastPage = 1; page <= lastPage; page++) {
|
||||||
seriesInfo.setAirsDayOfWeek(getTextContent("Airs_DayOfWeek", seriesNode));
|
Object json = requestJson("series/" + series.getId() + "/episodes?page=" + page, locale, Cache.ONE_DAY);
|
||||||
seriesInfo.setAirTime(getTextContent("Airs_Time", seriesNode));
|
lastPage = getInteger(getMap(json, "links"), "last");
|
||||||
seriesInfo.setCertification(getTextContent("ContentRating", seriesNode));
|
|
||||||
seriesInfo.setImdbId(getTextContent("IMDB_ID", seriesNode));
|
|
||||||
seriesInfo.setNetwork(getTextContent("Network", seriesNode));
|
|
||||||
seriesInfo.setOverview(getTextContent("Overview", seriesNode));
|
|
||||||
seriesInfo.setStatus(getTextContent("Status", seriesNode));
|
|
||||||
|
|
||||||
seriesInfo.setRating(getDecimal(getTextContent("Rating", seriesNode)));
|
streamJsonObjects(json, "data").forEach(it -> {
|
||||||
seriesInfo.setRatingCount(matchInteger(getTextContent("RatingCount", seriesNode)));
|
String episodeName = getString(it, "episodeName");
|
||||||
seriesInfo.setRuntime(matchInteger(getTextContent("Runtime", seriesNode)));
|
Integer absoluteNumber = getInteger(it, "absoluteNumber");
|
||||||
seriesInfo.setActors(getListContent("Actors", "\\|", seriesNode));
|
SimpleDate airdate = getStringValue(it, "firstAired", SimpleDate::parse);
|
||||||
seriesInfo.setGenres(getListContent("Genre", "\\|", seriesNode));
|
|
||||||
seriesInfo.setStartDate(SimpleDate.parse(getTextContent("FirstAired", seriesNode)));
|
|
||||||
|
|
||||||
seriesInfo.setBannerUrl(getResource(MirrorType.BANNER, getTextContent("banner", seriesNode)));
|
// default numbering
|
||||||
seriesInfo.setFanartUrl(getResource(MirrorType.BANNER, getTextContent("fanart", seriesNode)));
|
Integer episodeNumber = getInteger(it, "airedEpisodeNumber");
|
||||||
seriesInfo.setPosterUrl(getResource(MirrorType.BANNER, getTextContent("poster", seriesNode)));
|
Integer seasonNumber = getInteger(it, "airedSeason");
|
||||||
|
|
||||||
// parse episode data
|
// use preferred numbering if possible
|
||||||
List<Episode> episodes = new ArrayList<Episode>(50);
|
if (sortOrder == SortOrder.DVD) {
|
||||||
List<Episode> specials = new ArrayList<Episode>(5);
|
Integer dvdSeasonNumber = getInteger(it, "dvdSeason");
|
||||||
|
Integer dvdEpisodeNumber = getInteger(it, "dvdEpisodeNumber");
|
||||||
|
|
||||||
for (Node node : selectNodes("Data/Episode", dom)) {
|
// require both values to be valid integer numbers
|
||||||
String episodeName = getTextContent("EpisodeName", node);
|
if (dvdSeasonNumber != null && dvdEpisodeNumber != null) {
|
||||||
Integer absoluteNumber = matchInteger(getTextContent("absolute_number", node));
|
seasonNumber = dvdSeasonNumber;
|
||||||
SimpleDate airdate = SimpleDate.parse(getTextContent("FirstAired", node));
|
episodeNumber = dvdEpisodeNumber;
|
||||||
|
|
||||||
// default numbering
|
|
||||||
Integer episodeNumber = matchInteger(getTextContent("EpisodeNumber", node));
|
|
||||||
Integer seasonNumber = matchInteger(getTextContent("SeasonNumber", node));
|
|
||||||
|
|
||||||
// adjust for DVD numbering if possible
|
|
||||||
if (sortOrder == SortOrder.DVD) {
|
|
||||||
Integer dvdSeasonNumber = matchInteger(getTextContent("DVD_season", node));
|
|
||||||
Integer dvdEpisodeNumber = matchInteger(getTextContent("DVD_episodenumber", node));
|
|
||||||
|
|
||||||
// require both values to be valid integer numbers
|
|
||||||
if (dvdSeasonNumber != null && dvdEpisodeNumber != null) {
|
|
||||||
seasonNumber = dvdSeasonNumber;
|
|
||||||
episodeNumber = dvdEpisodeNumber;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// adjust for special numbering if necessary
|
|
||||||
if (seasonNumber == null || seasonNumber == 0) {
|
|
||||||
// handle as special episode
|
|
||||||
for (String specialSeasonTag : new String[] { "airsafter_season", "airsbefore_season" }) {
|
|
||||||
Integer specialSeason = matchInteger(getTextContent(specialSeasonTag, node));
|
|
||||||
if (specialSeason != null && specialSeason != 0) {
|
|
||||||
seasonNumber = specialSeason;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
} else if (sortOrder == SortOrder.Absolute && absoluteNumber != null && absoluteNumber > 0) {
|
||||||
|
episodeNumber = absoluteNumber;
|
||||||
|
seasonNumber = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// use given episode number as special number or count specials by ourselves
|
if (seasonNumber == null || seasonNumber > 0) {
|
||||||
Integer specialNumber = (episodeNumber != null) ? episodeNumber : filterBySeason(specials, seasonNumber).size() + 1;
|
// handle as normal episode
|
||||||
specials.add(new Episode(seriesInfo.getName(), seasonNumber, null, episodeName, null, specialNumber, airdate, new SeriesInfo(seriesInfo)));
|
episodes.add(new Episode(info.getName(), seasonNumber, episodeNumber, episodeName, absoluteNumber, null, airdate, new SeriesInfo(info)));
|
||||||
} else {
|
} else {
|
||||||
// adjust for absolute numbering if possible
|
// handle as special episode
|
||||||
if (sortOrder == SortOrder.Absolute) {
|
specials.add(new Episode(info.getName(), null, null, episodeName, null, episodeNumber, airdate, new SeriesInfo(info)));
|
||||||
if (absoluteNumber != null && absoluteNumber > 0) {
|
|
||||||
episodeNumber = absoluteNumber;
|
|
||||||
seasonNumber = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// handle as normal episode
|
|
||||||
episodes.add(new Episode(seriesInfo.getName(), seasonNumber, episodeNumber, episodeName, absoluteNumber, null, airdate, new SeriesInfo(seriesInfo)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// episodes my not be ordered by DVD episode number
|
// episodes my not be ordered by DVD episode number
|
||||||
@ -200,20 +203,16 @@ public class TheTVDBClient extends AbstractEpisodeListProvider implements Artwor
|
|||||||
// add specials at the end
|
// add specials at the end
|
||||||
episodes.addAll(specials);
|
episodes.addAll(specials);
|
||||||
|
|
||||||
return new SeriesData(seriesInfo, episodes);
|
return new SeriesData(info, episodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public SearchResult lookupByID(int id, Locale language) throws Exception {
|
public SearchResult lookupByID(int id, Locale locale) throws Exception {
|
||||||
if (id <= 0) {
|
if (id <= 0) {
|
||||||
throw new IllegalArgumentException("Illegal TheTVDB ID: " + id);
|
throw new IllegalArgumentException("Illegal TheTVDB ID: " + id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return getLookupCache("id", language).computeIfAbsent(id, it -> {
|
SeriesInfo info = getSeriesInfo(new SearchResult(id, null), locale);
|
||||||
Document dom = getXmlResource(MirrorType.XML, "series/" + id + "/all/" + getLanguageCode(language) + ".xml");
|
return new SearchResult(id, info.getName(), info.getAliasNames());
|
||||||
String name = selectString("//SeriesName", dom);
|
|
||||||
|
|
||||||
return new SearchResult(id, name);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public SearchResult lookupByIMDbID(int imdbid, Locale locale) throws Exception {
|
public SearchResult lookupByIMDbID(int imdbid, Locale locale) throws Exception {
|
||||||
@ -221,102 +220,8 @@ public class TheTVDBClient extends AbstractEpisodeListProvider implements Artwor
|
|||||||
throw new IllegalArgumentException("Illegal IMDbID ID: " + imdbid);
|
throw new IllegalArgumentException("Illegal IMDbID ID: " + imdbid);
|
||||||
}
|
}
|
||||||
|
|
||||||
return getLookupCache("imdbid", locale).computeIfAbsent(imdbid, it -> {
|
List<SearchResult> result = search("search/series", singletonMap("imdbId", String.format("tt%07d", imdbid)), locale, Cache.ONE_MONTH);
|
||||||
Document dom = getXmlResource(MirrorType.SEARCH, "GetSeriesByRemoteID.php?imdbid=" + imdbid + "&language=" + getLanguageCode(locale));
|
return result.size() > 0 ? result.get(0) : null;
|
||||||
|
|
||||||
String id = selectString("//seriesid", dom);
|
|
||||||
String name = selectString("//SeriesName", dom);
|
|
||||||
|
|
||||||
if (id.isEmpty() || name.isEmpty())
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return new SearchResult(Integer.parseInt(id), name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected String getMirror(MirrorType mirrorType) throws Exception {
|
|
||||||
// use default server
|
|
||||||
if (mirrorType == MirrorType.NULL) {
|
|
||||||
return "http://thetvdb.com";
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized (mirrors) {
|
|
||||||
// initialize mirrors
|
|
||||||
if (mirrors.isEmpty()) {
|
|
||||||
Document dom = getXmlResource(MirrorType.NULL, "mirrors.xml");
|
|
||||||
|
|
||||||
// collect all mirror data
|
|
||||||
Map<MirrorType, List<String>> mirrorLists = streamNodes("Mirrors/Mirror", dom).flatMap(node -> {
|
|
||||||
String mirror = getTextContent("mirrorpath", node);
|
|
||||||
int typeMask = Integer.parseInt(getTextContent("typemask", node));
|
|
||||||
|
|
||||||
return MirrorType.fromTypeMask(typeMask).stream().collect(toMap(m -> m, m -> mirror)).entrySet().stream();
|
|
||||||
}).collect(groupingBy(Entry::getKey, MirrorType::newMap, mapping(Entry::getValue, toList())));
|
|
||||||
|
|
||||||
// select random mirror for each type
|
|
||||||
Random random = new Random();
|
|
||||||
|
|
||||||
mirrorLists.forEach((type, options) -> {
|
|
||||||
String selection = options.get(random.nextInt(options.size()));
|
|
||||||
mirrors.put(type, selection);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// return selected mirror
|
|
||||||
return mirrors.get(mirrorType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Document getXmlResource(MirrorType mirror, String resource) throws Exception {
|
|
||||||
Cache cache = Cache.getCache(getName(), CacheType.Monthly);
|
|
||||||
return cache.xml(resource, s -> getResource(mirror, s)).get();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected URL getResource(MirrorType mirror, String path) throws Exception {
|
|
||||||
StringBuilder url = new StringBuilder(getMirror(mirror)).append('/').append(mirror.prefix()).append('/');
|
|
||||||
if (mirror.keyRequired()) {
|
|
||||||
url.append(apikey).append('/');
|
|
||||||
}
|
|
||||||
return new URL(url.append(path).toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static enum MirrorType {
|
|
||||||
|
|
||||||
NULL(0), SEARCH(1), XML(1), BANNER(2);
|
|
||||||
|
|
||||||
final int bitMask;
|
|
||||||
|
|
||||||
private MirrorType(int bitMask) {
|
|
||||||
this.bitMask = bitMask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String prefix() {
|
|
||||||
return this != BANNER ? "api" : "banners";
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean keyRequired() {
|
|
||||||
return this != BANNER && this != SEARCH;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static EnumSet<MirrorType> fromTypeMask(int mask) {
|
|
||||||
// convert bit mask to enumset
|
|
||||||
return EnumSet.of(SEARCH, XML, BANNER).stream().filter(m -> {
|
|
||||||
return (mask & m.bitMask) != 0;
|
|
||||||
}).collect(toCollection(MirrorType::newSet));
|
|
||||||
};
|
|
||||||
|
|
||||||
public static EnumSet<MirrorType> newSet() {
|
|
||||||
return EnumSet.noneOf(MirrorType.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static <T> EnumMap<MirrorType, T> newMap() {
|
|
||||||
return new EnumMap<MirrorType, T>(MirrorType.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public SeriesInfo getSeriesInfoByIMDbID(int imdbid, Locale locale) throws Exception {
|
|
||||||
return getSeriesInfo(lookupByIMDbID(imdbid, locale), locale);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -326,29 +231,24 @@ public class TheTVDBClient extends AbstractEpisodeListProvider implements Artwor
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Artwork> getArtwork(int id, String category, Locale locale) throws Exception {
|
public List<Artwork> getArtwork(int id, String category, Locale locale) throws Exception {
|
||||||
Document dom = getXmlResource(MirrorType.XML, "series/" + id + "/banners.xml");
|
Object json = requestJson("series/" + id + "/images/query?keyType=" + category, locale, Cache.ONE_WEEK);
|
||||||
URL mirror = getResource(MirrorType.BANNER, "");
|
|
||||||
|
|
||||||
return streamNodes("//Banner", dom).map(node -> {
|
// TheTVDB API v2 does not have a dedicated banner mirror
|
||||||
|
URL mirror = new URL("http://thetvdb.com/banners/");
|
||||||
|
|
||||||
|
return streamJsonObjects(json, "data").map(it -> {
|
||||||
try {
|
try {
|
||||||
String type = getTextContent("BannerType", node);
|
String subKey = getString(it, "subKey");
|
||||||
String subKey = getTextContent("BannerType2", node);
|
String fileName = getString(it, "fileName");
|
||||||
String fileName = getTextContent("BannerPath", node);
|
String resolution = getString(it, "resolution");
|
||||||
String season = getTextContent("Season", node);
|
Double rating = getDecimal(getString(it, "ratingsInfo"), "average");
|
||||||
String language = getTextContent("Language", node);
|
|
||||||
Double rating = getDecimal(getTextContent("Rating", node));
|
|
||||||
|
|
||||||
return new Artwork(this, Stream.of(type, subKey, season), new URL(mirror, fileName), language == null ? null : new Locale(language), rating);
|
return new Artwork(this, Stream.of(category, subKey, resolution), new URL(mirror, fileName), locale, rating);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
debug.log(Level.WARNING, e, e::getMessage);
|
debug.log(Level.WARNING, e, e::getMessage);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}).filter(Objects::nonNull).filter(it -> it.getTags().contains(category)).collect(toList());
|
}).filter(Objects::nonNull).collect(toList());
|
||||||
}
|
|
||||||
|
|
||||||
protected TypedCache<SearchResult> getLookupCache(String type, Locale language) {
|
|
||||||
// lookup should always yield the same results so we can cache it for longer
|
|
||||||
return Cache.getCache(getName() + "_" + "lookup" + "_" + type + "_" + language, CacheType.Monthly).cast(SearchResult.class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,254 +0,0 @@
|
|||||||
package net.filebot.web;
|
|
||||||
|
|
||||||
import static java.nio.charset.StandardCharsets.*;
|
|
||||||
import static java.util.Arrays.*;
|
|
||||||
import static java.util.Collections.*;
|
|
||||||
import static java.util.stream.Collectors.*;
|
|
||||||
import static net.filebot.CachedResource.fetchIfModified;
|
|
||||||
import static net.filebot.Logging.*;
|
|
||||||
import static net.filebot.util.JsonUtilities.*;
|
|
||||||
import static net.filebot.util.StringUtilities.*;
|
|
||||||
import static net.filebot.web.EpisodeUtilities.*;
|
|
||||||
import static net.filebot.web.WebRequest.*;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URL;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.logging.Level;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
import javax.swing.Icon;
|
|
||||||
|
|
||||||
import net.filebot.Cache;
|
|
||||||
import net.filebot.CacheType;
|
|
||||||
import net.filebot.ResourceManager;
|
|
||||||
|
|
||||||
public class TheTVDBClient2 extends AbstractEpisodeListProvider implements ArtworkProvider {
|
|
||||||
|
|
||||||
private String apikey;
|
|
||||||
|
|
||||||
public TheTVDBClient2(String apikey) {
|
|
||||||
this.apikey = apikey;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getName() {
|
|
||||||
return "TheTVDB";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Icon getIcon() {
|
|
||||||
return ResourceManager.getIcon("search.thetvdb");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean hasSeasonSupport() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Object postJson(String path, Object json) throws Exception {
|
|
||||||
// curl -X POST --header 'Content-Type: application/json' --header 'Accept: application/json' 'https://api.thetvdb.com/login' --data '{"apikey":"XXXXX"}'
|
|
||||||
ByteBuffer response = post(getEndpoint(path), asJsonString(json).getBytes(UTF_8), "application/json", null);
|
|
||||||
return readJson(UTF_8.decode(response));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Object requestJson(String path, Locale locale, Duration expirationTime) throws Exception {
|
|
||||||
Cache cache = Cache.getCache(locale == null || locale == Locale.ROOT ? getName() : getName() + "_" + locale.getLanguage(), CacheType.Monthly);
|
|
||||||
return cache.json(path, this::getEndpoint).fetch(fetchIfModified(() -> getRequestHeader(locale))).expire(expirationTime).get();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected URL getEndpoint(String path) throws Exception {
|
|
||||||
return new URL("https://api.thetvdb.com/" + path);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, String> getRequestHeader(Locale locale) {
|
|
||||||
Map<String, String> header = new LinkedHashMap<String, String>(3);
|
|
||||||
if (locale != null && locale != Locale.ROOT) {
|
|
||||||
header.put("Accept-Language", locale.getLanguage());
|
|
||||||
}
|
|
||||||
header.put("Accept", "application/json");
|
|
||||||
header.put("Authorization", "Bearer " + getAuthorizationToken());
|
|
||||||
return header;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String token = null;
|
|
||||||
private Instant tokenExpireInstant = null;
|
|
||||||
private Duration tokenExpireDuration = Duration.ofHours(1);
|
|
||||||
|
|
||||||
private String getAuthorizationToken() {
|
|
||||||
synchronized (tokenExpireDuration) {
|
|
||||||
if (token == null || (tokenExpireInstant != null && Instant.now().isAfter(tokenExpireInstant))) {
|
|
||||||
try {
|
|
||||||
Object json = postJson("login", singletonMap("apikey", apikey));
|
|
||||||
token = getString(json, "token");
|
|
||||||
tokenExpireInstant = Instant.now().plus(tokenExpireDuration);
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new IllegalStateException("Failed to retrieve authorization token: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected String[] languages() throws Exception {
|
|
||||||
Object response = requestJson("languages", Locale.ROOT, Cache.ONE_MONTH);
|
|
||||||
return streamJsonObjects(response, "data").map(it -> getString(it, "abbreviation")).toArray(String[]::new);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected List<SearchResult> search(String path, Map<String, Object> query, Locale locale, Duration expirationTime) throws Exception {
|
|
||||||
Object json = requestJson(path + "?" + encodeParameters(query, true), locale, expirationTime);
|
|
||||||
|
|
||||||
return streamJsonObjects(json, "data").map(it -> {
|
|
||||||
// e.g. aliases, banner, firstAired, id, network, overview, seriesName, status
|
|
||||||
int id = getInteger(it, "id");
|
|
||||||
String seriesName = getString(it, "seriesName");
|
|
||||||
String[] aliasNames = stream(getArray(it, "aliases")).toArray(String[]::new);
|
|
||||||
|
|
||||||
if (seriesName.startsWith("**") && seriesName.endsWith("**")) {
|
|
||||||
debug.fine(format("Invalid series: %s [%d]", seriesName, id));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new SearchResult(id, seriesName, aliasNames);
|
|
||||||
}).filter(Objects::nonNull).collect(toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<SearchResult> fetchSearchResult(String query, Locale locale) throws Exception {
|
|
||||||
return search("search/series", singletonMap("name", query), locale, Cache.ONE_DAY);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public SeriesInfo getSeriesInfo(SearchResult series, Locale locale) throws Exception {
|
|
||||||
Object json = requestJson("series/" + series.getId(), locale, Cache.ONE_WEEK);
|
|
||||||
Object data = getMap(json, "data");
|
|
||||||
|
|
||||||
SeriesInfo info = new SeriesInfo(this, locale, series.getId());
|
|
||||||
info.setAliasNames(Stream.of(series.getAliasNames(), getArray(data, "aliases")).flatMap(it -> stream(it)).map(Object::toString).distinct().toArray(String[]::new));
|
|
||||||
|
|
||||||
info.setName(getString(data, "seriesName"));
|
|
||||||
info.setCertification(getString(data, "rating"));
|
|
||||||
info.setNetwork(getString(data, "network"));
|
|
||||||
info.setStatus(getString(data, "status"));
|
|
||||||
|
|
||||||
info.setRating(getDecimal(data, "siteRating"));
|
|
||||||
info.setRatingCount(getInteger(data, "siteRatingCount")); // TODO rating count not implemented in the new API yet
|
|
||||||
|
|
||||||
info.setRuntime(matchInteger(getString(data, "runtime")));
|
|
||||||
info.setGenres(stream(getArray(data, "genre")).map(Object::toString).collect(toList()));
|
|
||||||
info.setStartDate(getStringValue(data, "firstAired", SimpleDate::parse));
|
|
||||||
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected SeriesData fetchSeriesData(SearchResult series, SortOrder sortOrder, Locale locale) throws Exception {
|
|
||||||
// fetch series info
|
|
||||||
SeriesInfo info = getSeriesInfo(series, locale);
|
|
||||||
info.setOrder(sortOrder.name());
|
|
||||||
|
|
||||||
// fetch episode data
|
|
||||||
List<Episode> episodes = new ArrayList<Episode>();
|
|
||||||
List<Episode> specials = new ArrayList<Episode>();
|
|
||||||
|
|
||||||
for (int page = 1, lastPage = 1; page <= lastPage; page++) {
|
|
||||||
Object json = requestJson("series/" + series.getId() + "/episodes?page=" + page, locale, Cache.ONE_DAY);
|
|
||||||
lastPage = getInteger(getMap(json, "links"), "last");
|
|
||||||
|
|
||||||
streamJsonObjects(json, "data").forEach(it -> {
|
|
||||||
String episodeName = getString(it, "episodeName");
|
|
||||||
Integer absoluteNumber = getInteger(it, "absoluteNumber");
|
|
||||||
SimpleDate airdate = getStringValue(it, "firstAired", SimpleDate::parse);
|
|
||||||
|
|
||||||
// default numbering
|
|
||||||
Integer episodeNumber = getInteger(it, "airedEpisodeNumber");
|
|
||||||
Integer seasonNumber = getInteger(it, "airedSeason");
|
|
||||||
|
|
||||||
// use preferred numbering if possible
|
|
||||||
if (sortOrder == SortOrder.DVD) {
|
|
||||||
Integer dvdSeasonNumber = getInteger(it, "dvdSeason");
|
|
||||||
Integer dvdEpisodeNumber = getInteger(it, "dvdEpisodeNumber");
|
|
||||||
|
|
||||||
// require both values to be valid integer numbers
|
|
||||||
if (dvdSeasonNumber != null && dvdEpisodeNumber != null) {
|
|
||||||
seasonNumber = dvdSeasonNumber;
|
|
||||||
episodeNumber = dvdEpisodeNumber;
|
|
||||||
}
|
|
||||||
} else if (sortOrder == SortOrder.Absolute && absoluteNumber != null && absoluteNumber > 0) {
|
|
||||||
episodeNumber = absoluteNumber;
|
|
||||||
seasonNumber = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seasonNumber == null || seasonNumber > 0) {
|
|
||||||
// handle as normal episode
|
|
||||||
episodes.add(new Episode(info.getName(), seasonNumber, episodeNumber, episodeName, absoluteNumber, null, airdate, new SeriesInfo(info)));
|
|
||||||
} else {
|
|
||||||
// handle as special episode
|
|
||||||
specials.add(new Episode(info.getName(), null, null, episodeName, null, episodeNumber, airdate, new SeriesInfo(info)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// episodes my not be ordered by DVD episode number
|
|
||||||
episodes.sort(episodeComparator());
|
|
||||||
|
|
||||||
// add specials at the end
|
|
||||||
episodes.addAll(specials);
|
|
||||||
|
|
||||||
return new SeriesData(info, episodes);
|
|
||||||
}
|
|
||||||
|
|
||||||
public SearchResult lookupByID(int id, Locale locale) throws Exception {
|
|
||||||
if (id <= 0) {
|
|
||||||
throw new IllegalArgumentException("Illegal TheTVDB ID: " + id);
|
|
||||||
}
|
|
||||||
|
|
||||||
SeriesInfo info = getSeriesInfo(new SearchResult(id, null), locale);
|
|
||||||
return new SearchResult(id, info.getName(), info.getAliasNames());
|
|
||||||
}
|
|
||||||
|
|
||||||
public SearchResult lookupByIMDbID(int imdbid, Locale locale) throws Exception {
|
|
||||||
if (imdbid <= 0) {
|
|
||||||
throw new IllegalArgumentException("Illegal IMDbID ID: " + imdbid);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<SearchResult> result = search("search/series", singletonMap("imdbId", String.format("tt%07d", imdbid)), locale, Cache.ONE_MONTH);
|
|
||||||
return result.size() > 0 ? result.get(0) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public URI getEpisodeListLink(SearchResult searchResult) {
|
|
||||||
return URI.create("http://www.thetvdb.com/?tab=seasonall&id=" + searchResult.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<Artwork> getArtwork(int id, String category, Locale locale) throws Exception {
|
|
||||||
Object json = requestJson("series/" + id + "/images/query?keyType=" + category, locale, Cache.ONE_WEEK);
|
|
||||||
|
|
||||||
// TheTVDB API v2 does not have a dedicated banner mirror
|
|
||||||
URL mirror = new URL("http://thetvdb.com/banners/");
|
|
||||||
|
|
||||||
return streamJsonObjects(json, "data").map(it -> {
|
|
||||||
try {
|
|
||||||
String subKey = getString(it, "subKey");
|
|
||||||
String fileName = getString(it, "fileName");
|
|
||||||
String resolution = getString(it, "resolution");
|
|
||||||
Double rating = getDecimal(getString(it, "ratingsInfo"), "average");
|
|
||||||
|
|
||||||
return new Artwork(this, Stream.of(category, subKey, resolution), new URL(mirror, fileName), locale, rating);
|
|
||||||
} catch (Exception e) {
|
|
||||||
debug.log(Level.WARNING, e, e::getMessage);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}).filter(Objects::nonNull).collect(toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user